Commit graph

55 commits

Author SHA1 Message Date
lefarcen
e1bc83a476
feat(analytics): PostHog product analytics (P0 events, consent-gated, packaged) (#1428)
* feat(analytics): scaffold PostHog product-analytics integration

- Add @open-design/contracts/analytics subpath with the 17 P0 event
  payload types, header constants, and code↔CSV enum mapping helpers.
- Add apps/daemon/src/analytics.ts with env-gated posthog-node client,
  request-scoped analytics context reader, and artifact-id anonymizer.
- Expose GET /api/analytics/config so the web bundle never embeds the
  PostHog key at build time; daemon owns POSTHOG_KEY / POSTHOG_HOST.
- Add apps/web/src/analytics module (identity + lazy posthog-js client
  + React provider) and mount it under <I18nProvider> in app/layout.

No event wiring yet — that lands in the next commit alongside trigger
points (App.tsx, EntryView, NewProjectPanel, SettingsDialog, FileViewer,
runs.ts).

* feat(analytics): wire app_launch, home_view, home_click, project_create_result

- App.tsx: fire app_launch once after first effect tick. handleCreateProject
  now emits project_create_result on both success and failure paths.
- EntryView.tsx: home_view (page) gated on agents loading so
  has_available_cli isn't transiently false; home_view (asset_panel) fires
  per top-tab change with the right result_count.
- NewProjectPanel.tsx: home_click create_button fires before delegating to
  the parent; a fresh request_id is generated here and threaded through
  onCreate so the matching project_create_result stitches via $insert_id.
- contracts/analytics: tighten createTabToTracking and topTabToTracking
  for the worktree branch's renamed tabs (live-artifact, templates).

* feat(analytics): wire settings_view + 3 settings_click events

- settings_view fires on dialog mount and on every section switch,
  carrying the active section (mapped via settingsSectionToTracking
  for the 16-section worktree layout), execution_mode, and the
  selected CLI provider id when present.
- settings_click execution_mode_tab: setMode now emits before/after
  values whenever the user toggles between Local CLI and BYOK.
- settings_click cli_provider_card: agent card onClick reports
  cli_provider_id via agentIdToTracking (kiro → other).
- settings_click byok_field: onFocus added to api_key, model select,
  and base_url inputs; provider_id widened to include google so the
  worktree's Gemini protocol slot type-checks.

* feat(analytics): wire studio_view + studio_click chat, studio_view artifact

- packages/contracts/src/analytics/artifact-id.ts: FNV-1a 64-bit helper
  produces a 16-hex anonymized id for (projectId, fileName). Stable
  cross-platform so the daemon and the web bundle resolve the same id
  without a Web Crypto round-trip; daemon now re-exports it.
- ChatComposer: studio_view chat_panel fires once per project mount,
  studio_click chat_composer fires on attachment + send buttons with
  estimated user_query_tokens (length/4) and has_attachment.
- FileViewer: studio_view artifact fires once per (project, file) at
  the dispatcher level, before any sub-viewer renders, with
  artifact_kind derived from the renderer registry / file.kind table.
- Widen TrackingExportFormat to include markdown and cloudflare_pages
  so the worktree branch's full share menu can emit verbatim.

* feat(analytics): wire studio_click share_option + artifact_export_result

HtmlViewer's share menu now emits both events per click via a
fireShareExport helper:

- studio_click share_option fires immediately on click with the chosen
  export_format and a fresh request_id.
- artifact_export_result fires when the export resolves — success for
  sync exporters (html, markdown, template) the moment the call
  returns, success/failed for async exporters (pdf, zip, deploy)
  via .then/.catch. The same request_id threads both events so
  PostHog stitches click → result via $insert_id.

DEPLOY_PROVIDER_OPTIONS maps to the CSV's vercel / cloudflare_pages
slots; markdown is now a first-class export_format value.

Also ignore .env.local so local POSTHOG_KEY / .env-style secrets
don't get committed.

* feat(analytics): emit run_created and run_finished from the daemon

POST /api/runs now reads the analytics context off the
x-od-analytics-* headers the web client sets on every fetch, then:

- Captures run_created with project_id, conversation_id, run_id,
  model_id, agent_provider_id (mapped via agentIdToTracking),
  skill_id, design_system_id, plus the token_count_source marker.
- Schedules a run_finished capture on runs.wait(run) resolution,
  mapping succeeded/canceled/failed to success/cancelled/failed and
  reporting total_duration_ms.

Both events use a stable insert_id derived from the same uuid so
PostHog dedupes the daemon-side mirror against any future
web-side capture without double-counting.

Token sub-fields (user_query_tokens/system_prompt_tokens/...) stay
omitted in v1 — the claude-stream parser only exposes input/output
totals today. See tracking-doc-issues.md §3.2.

* feat(analytics): emit settings_cli_test_result + settings_byok_test_result

The original BLOCKING-list assumed these CSV P0 events were not
implementable in this branch because main lacked Test buttons. The
worktree HEAD actually wires `handleTestAgent` and `handleTestProvider`
in SettingsDialog, so both events are now in scope.

- handleTestAgent emits settings_cli_test_result on success and
  failure paths with cli_provider_id mapped via agentIdToTracking,
  result drawn from result.ok / catch branch, error_code from
  result.kind or the thrown error name, and duration_ms timed via
  performance.now().
- handleTestProvider emits settings_byok_test_result analogously,
  using apiProtocol (anthropic|openai|azure|ollama|google) directly
  as provider_id — wider than the CSV's 5-value enum, documented in
  tracking-doc-issues.md §2.5.

Contracts: add SettingsCliTestResultProps / SettingsByokTestResultProps
plus matching track* helpers. AnalyticsEventName union now covers all
14 P0 events this branch supports.

* feat(analytics): gate PostHog on the existing telemetry.metrics consent

The integration now reuses the same first-launch privacy banner +
Settings → Privacy toggle that gates Langfuse, so a single user
decision controls both telemetry sinks.

- /api/analytics/config now consults the persisted AppConfigPrefs:
  it returns enabled=true only when POSTHOG_KEY is set AND the user
  has chosen "Share usage data" (telemetry.metrics === true). The
  response also echoes installationId so the web client uses the
  same anonymous id Langfuse keys off of — one identity per install,
  shared across both sinks.
- Web AnalyticsProvider:
  - Bootstrap fetch resolves installationId and threads it through
    the x-od-analytics-anonymous-id header on every /api/* fetch,
    so daemon-side captures (run_created / run_finished /
    project_create_result) land on the same person record.
  - Exposes a setConsent(granted) method that calls posthog-js's
    opt_in_capturing / opt_out_capturing, wired from App.tsx via a
    useEffect watching config.telemetry?.metrics. Toggling Privacy
    → metrics now stops/resumes events immediately, no reload.
- app_launch additionally gates on telemetry.metrics so a freshly-
  declined user fires nothing, and a freshly-opted-in user fires on
  the next reload.

* feat(packaging): bake POSTHOG_KEY into packaged daemon spawn env

Wires PostHog product analytics through the same Langfuse-style build-
secret pipeline so official Open Design builds ship with the key while
fork builds compile without it (the integration short-circuits cleanly
when POSTHOG_KEY is absent).

tools/pack
- resolveToolPackConfig reads POSTHOG_KEY / POSTHOG_HOST from
  process.env at packaging time, validates them (no whitespace in the
  key, http(s) URL for host, trailing-slash strip), and stamps them on
  ToolPackConfig. Fork builds without the env vars simply omit the
  fields; the daemon-side gate keeps things off in that case.
- Mac, Windows, and Linux packaged-config writers each append the two
  fields to open-design-config.json next to the existing
  telemetryRelayUrl entry.

apps/packaged
- RawPackagedConfig / PackagedConfig surface posthogKey / posthogHost
  so the Electron entry and headless entry both forward them to the
  daemon sidecar.
- buildPackagedDaemonSpawnEnv emits POSTHOG_KEY / POSTHOG_HOST into
  the daemon child env when present. The daemon's existing analytics
  module reads these via process.env — no daemon-side changes needed.
- The headless packaged path falls back to process.env for fields the
  builder hasn't injected, mirroring how OPEN_DESIGN_TELEMETRY_RELAY_URL
  is read there.

CI
- release-beta.yml and release-stable.yml expose POSTHOG_KEY (secret)
  and POSTHOG_HOST (var) at workflow-env scope so every packaging job
  inherits them. PR / fork builds without these set simply skip the
  bake step.

Tests
- tools/pack: config.test.ts covers bake-through, fork-build omission,
  whitespace rejection, invalid-URL rejection, and trailing-slash
  normalization.
- apps/packaged: sidecars.test.ts covers buildPackagedDaemonSpawnEnv
  forwarding the keys when present and omitting them when null.

* feat(analytics): enable PostHog autocapture + perf + exceptions

Flip on the PostHog SDK's automatic diagnostic features so we capture
click paths, page transitions, web vitals, dead clicks, and browser
exceptions without scattering instrumentation through the codebase.

Privacy defense lives in one place — apps/web/src/analytics/scrub.ts —
wired in via posthog-js's `before_send` hook so every outgoing event
passes through the same audit point:

  - $autocapture / $rageclick / $dead_click / $copy_autocapture:
    strips $el_text and value/placeholder/aria-label attrs from any
    input, textarea, password input, or contenteditable element. PostHog
    autocapture does not capture input.value by default, but $el_text
    on a <textarea> reflects the typed content — that's the prompt
    body for us, so it has to be scrubbed every time.
  - $pageview / $pageleave: drops query string and fragment from
    $current_url / $referrer so any future ?q=… can't leak.
  - $exception: rewrites file:// and absolute filesystem paths in
    stack frames to app://apps/<repo-relative> so we don't ship the
    user's home directory.
  - Suppresses $opt_in entirely — duplicate of our explicit
    setConsent toggle in App.tsx.

Element-level defense in depth is limited to the single most sensitive
surface: the chat composer textarea gets `ph-no-capture` so PostHog
never even generates an event for clicks inside that subtree. Every
other input relies on scrub.ts — sprinkling the class through every
form would be noisy and easy to forget on new surfaces.

The existing Privacy → "Share usage data" toggle continues to gate
every new feature: posthog-js's opt_out_capturing() halts autocapture,
$pageview, $exception, web vitals, and dead clicks alongside the
explicit capture() calls — one global switch.

11 unit tests pin the scrub rules in apps/web/tests/analytics-scrub.test.ts.

* ci(nix): bump pnpmDepsHash for posthog-js + posthog-node additions

Adding posthog-js to apps/web and posthog-node to apps/daemon changed
pnpm-lock.yaml, which Nix's fixed-output pnpmDeps derivation pins by
sha256. The CI nix flake check failed with:

  specified: sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=
  got:       sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=

Copy the new hash into both nix/package-web.nix and
nix/package-daemon.nix per the procedure documented in nix/README.md
§"First-build hash pinning".

* feat(analytics): unify PostHog identity with Langfuse installationId

PostHog's distinct_id is the installationId stamped by /api/analytics/
config; Langfuse already reads the same id off app-config.json to
populate trace.userId. With both sinks keying off the same anonymous
identity, dashboards can correlate user actions (PostHog events) with
LLM runs (Langfuse traces) without re-identifying.

Two gaps closed:

1. applyConsent(false) — clear posthog-js's persisted ph_*_posthog
   localStorage entry on opt-out via posthog.reset(). Without this, a
   user who opts out, then clicks Delete my data, then re-opts in
   would see PostHog stitch their new session to the deleted identity
   because bootstrap.distinctID only takes effect on first init.

2. applyIdentity(newInstallationId) — Delete my data rotates the
   installationId in app-config; App.tsx now watches config.installationId
   and calls posthog.reset() then identify(newId) so the next event
   batch is fully decoupled from the deleted one. Idempotent on
   same-id re-renders so benign config refreshes don't churn PostHog
   identities.

The fetch wrapper's x-od-analytics-anonymous-id header also flips to
the new id on rotation so daemon-side captures (run_created /
run_finished) land on the same person record from the very next API
call, not after a reload.

The end-to-end rotation flow is verified against a live PostHog
project; these unit tests pin the safety guards (no-client paths, null
inputs) since stubbing posthog-js's init-loaded callback chain is
brittle.

* fix(langfuse): require both metrics AND content consent for trace reports

Tightens the Langfuse gate so a user who shares anonymous metrics but
NOT conversation content stops emitting Langfuse traces entirely —
Langfuse is used for turn-quality evals which only make sense with
prompt/output bodies. PostHog (product analytics, content-free) stays
gated on `metrics` alone and is unaffected.

i18n: "Conversation content" → "Conversation and tool content" with
hints expanded to mention tool inputs/outputs so the consent surface
matches what the trace actually carries (en + zh-CN).

Bundled here per PR scope — change originated outside this PostHog
PR but lands cleanly on the same files; gating Langfuse strictly
on `content` makes the dual-sink consent model (PostHog = metrics,
Langfuse = metrics + content) symmetric across both i18n locales and
the daemon-side gate.

* feat(analytics): wire byok_provider_option + fix PR review P1s

Adds the BYOK protocol-chip click event (5-value provider_id mirroring
the apiProtocol Settings UI) and resolves four P1 review threads on
PR #1428.

byok_provider_option:
- New SettingsClickByokProviderOptionProps in contracts (provider_id =
  anthropic|openai|azure|google|ollama; maps to CSV's 5 values per
  tracking-doc-issues.md §2.5).
- trackSettingsClickByokProviderOption helper in apps/web/src/analytics.
- SettingsDialog hooks it on the protocol-chip onClick alongside the
  existing setApiProtocol call; is_selected reflects whether the chip
  was already active.

Review fixes:

1. client.ts (Siri-Ray): clear `initPromise` when the resolution is
   null so a Privacy → metrics opt-in after a previous decline triggers
   a fresh /api/analytics/config fetch. Without this, the disabled
   response was cached forever — first-session opt-in needed a reload
   to start sending PostHog events.

2. provider.tsx (Siri-Ray): replace `url.includes('/api/')` with a
   strict same-origin + /api/ pathname check (shared
   `isSameOriginApiCall` helper). Outbound third-party URLs containing
   `/api/` (e.g. provider.example.com/api/x) no longer receive our
   x-od-analytics-* headers.

3. provider.tsx (codex-connector, lefarcen): gate header injection on
   `resolvedAnonId` being non-null. When Privacy → metrics is off,
   /api/analytics/config returns enabled=false → resolvedAnonId stays
   null → wrapper never installs → daemon can't read consent-bearing
   headers → no daemon-side PostHog event. setConsent now also clears
   resolvedAnonId on opt-out and re-fetches on opt-in.

4. daemon/analytics.ts (defense in depth): createAnalyticsService now
   takes dataDir and capture() re-reads app-config to check
   telemetry.metrics inside the fire-and-forget wrapper. Even if a
   stale header somehow reaches the daemon after opt-out, the capture
   is dropped before posthog-node.capture is called.

* fix(web): place "Share usage data" on the right in privacy consent banner

Swap button order in PrivacyConsentModal and the in-settings ConsentCard
so the affirmative "Share usage data" lands on the right and "Not now"
on the left. Matches the OK-on-the-right pattern users expect for
primary actions.

Both buttons keep equal visual prominence (same .privacy-consent-action
styling) so the swap doesn't change the EDPB equal-prominence stance
called out in the original Langfuse telemetry spec.

* feat(analytics): populate run_finished token totals from claude-stream usage

Daemon's claude-stream parser already emits agent usage events with
input_tokens / output_tokens totals; the run service buffers them in
run.events and Langfuse reads them out the same way. The run_finished
PostHog event was leaving these fields empty.

Scan run.events for the most recent agent usage frame on terminal
transition and emit input_tokens / output_tokens / total_tokens when
present. token_count_source flips to 'provider_usage' only when at
least one count landed; runs without provider-side usage data keep
'unknown'.

Provider does not break the input down into the 7 sub-fields the
tracking doc lists (memory / context / attachment / system_prompt /
…); those stay omitted until a parser change exposes them.

* feat(analytics): estimate user_query_tokens from prompt length

The user_query_tokens field for run_created / run_finished was hardcoded
to 0. We can't tokenize without bundling a model-specific tokenizer, but
the character/4 heuristic is the industry-standard estimate when one
isn't available and is enough for funnel analysis (prompt-length cohorts,
short-vs-long-query conversion rates).

Extracted from req.body via the same telemetryPromptFromRunRequest
pattern the daemon already uses for langfuse-bridge (currentPrompt then
message fallback). Only the integer count goes to PostHog — the prompt
text itself never leaves the daemon.

token_count_source flips appropriately:
- run_created with a prompt: 'estimated' (was 'unknown')
- run_created with no prompt: 'unknown'
- run_finished with provider usage: 'provider_usage' (overrides
  baseProps' 'estimated' value)
- run_finished without provider usage: inherits 'estimated' or 'unknown'
  from baseProps so input/output absent doesn't mask the estimate.
2026-05-12 22:32:42 +08:00
lefarcen
2a0ebea50b release: Open Design 0.7.0
- bump 14 monorepo package.json files to 0.7.0 (root + apps/{web,daemon,desktop,packaged,landing-page} + packages/{contracts,platform,sidecar,sidecar-proto} + tools/{dev,pack,pr} + e2e); apps/packaged was already at 0.6.1 from beta lane, all others at 0.6.0
- add CHANGELOG.md [0.7.0] - 2026-05-12 entry covering 97 merged PRs since 0.6.0:
  - Critique Theater: Phase 7 web client state machine (#1307) + Phase 6.2 daemon artifact extraction (#1085)
  - Web/UI: thumbs-up/down feedback widget (#1308), Cmd+, opens Settings (#1173), Finalize design package + Continue in CLI (#974), fetch models button for BYOK (#1034), provider models alphabetical sort (#1097), collapsible MCP JSON field-mapping (#1136), design file rename (#894)
  - Daemon: auto-memory store with chat-protocol-aware extraction (#999), install/uninstall skills & design systems (#1003), HTTP 206 range requests for video/audio (#1105), scheduled routines (#1033), agent runtime + route registration refactor (#1063, #1043)
  - HyperFrames: HTML-in-Canvas across web + skills (#866)
  - Skills/design systems: generic skills + design-templates split + finalize-design API (#955), agent-browser skill (#1284), WeChat design system + login-flow skill (#1083), hud/loom/trading-terminal design systems (#1069), release-notes-one-pager skill (#873), tokens.css schema (#1231)
  - Packaging: macOS Intel (x64) build (#759), official Nix flake (#402), beta packaging cache (#1095)
  - Maintainer ops: tools-pr PR-duty workspace (#1259), MAINTAINERS.md (#1290), contributor card bot (#932), PR→issue linking discipline (#1263)
  - Changed: conversation run isolation (#1271), default English i18n fallback (#1270), Codex CLI exit diagnostics / empty-response handling / path fallback (#1267, #1244, #1205)
  - Fixed: ~30 web + desktop + daemon + packaging bugfixes
  - Internal: nightly UI/desktop regression coverage (#1256), e2e/release report hardening (#1140), entry/settings automation (#954)
- catch up [Unreleased] compare link to v0.7.0 and add missing [0.6.0] release link
- add 97 PR footnote refs ([#402]..[#1330])

Verified locally: pnpm install + pre-build contracts/daemon/desktop dist + pnpm typecheck (exit 0 across all 14 packages on Node 22.22 with engine-warning).

Release workflow validation runs after merge via release-stable.
2026-05-12 15:33:28 +08:00
lefarcen
43f7fc536a
Add Langfuse telemetry relay (#1296)
* Add Langfuse telemetry relay

* Configure telemetry worker custom domain

* Add telemetry relay health check

* Harden telemetry relay config
2026-05-12 13:59:19 +08:00
PerishFire
819c34fd8f
fix(tools-pr): fall back on reviewDecision for unresolved-changes-requested (#1287)
* fix(tools-pr): fall back on reviewDecision for unresolved-changes-requested

Patrol classify on the live 102-PR queue missed three PRs (#1101, #1127,
#1163) where GitHub's reviewDecision is CHANGES_REQUESTED but the
classify tag did not fire.

Root cause is a divergence between two notions of "latest review state
per reviewer":

- GitHub's reviewDecision keeps a reviewer's CHANGES_REQUESTED in effect
  until that same reviewer submits APPROVED or DISMISSED. A subsequent
  COMMENTED review by the same reviewer does NOT supersede it.
- Our `reduceLatestReviewsByAuthor` collapses every reviewer to their
  latest review with no special-casing of state, so a CHANGES_REQUESTED
  followed by COMMENTED disappears from the reduced view.

`tagUnresolvedChangesRequested` filtered the reduced view for
`state === "CHANGES_REQUESTED"`, so the three PRs above (each had a
reviewer write CHANGES_REQUESTED → COMMENTED) escaped the rule even
though the PR-level reviewDecision was still CHANGES_REQUESTED.

Add a narrow fallback: when the first path returns no per-reviewer
reviewers, trust `facts.reviewDecision === "CHANGES_REQUESTED"` as the
source of truth. The fallback reason and source token differ from the
first path so report consumers can tell which signal fired.

Reducer semantics left alone on purpose — flipping COMMENTED handling
there would cascade to `bot-only-approval`, `stale-approval`, and
`humanReviewerSignalAt`, each of which has its own correctness story.

* fix(tools-pr): keep fallback reason strictly factual

Codex flagged that the fallback path's reason text asserted a specific
review sequence ("CHANGES_REQUESTED then COMMENTED") that the condition
alone does not prove. The condition only observes:

- `facts.reviewDecision === "CHANGES_REQUESTED"`, and
- after `reduceLatestReviewsByAuthor`, no review carries
  `state === "CHANGES_REQUESTED"`.

Multiple GitHub configurations satisfy that pair — a reviewer's CR
followed by COMMENTED, a CR that sits outside the `reviews(last: 30)`
fetch window, etc. Per `tools/pr/AGENTS.md`'s strictly-factual rule,
the reason must report only what is directly observed, not the most
likely upstream cause.

Drop the inferred-cause clause from `reason`; move the explanation of
possible upstream causes into the code comment above the branch where
it does not show up in classify output.

* docs(tools-pr): document fallback data source for unresolved-changes-requested

Siri-Ray and lefarcen both flagged that the tag dictionary row for
`unresolved-changes-requested` only describes the primary per-reviewer
path. The fallback added earlier in this PR emits the same tag with a
different `source` token (`gh.reviewDecision` vs the original
`gh.latestReviews[].state`), so report consumers need the dictionary to
list both paths to interpret which one fired.

Update the row to call out both: the primary per-reviewer rule, and the
PR-level reviewDecision fallback that fires when no per-reviewer CR
survives the latest-per-author reduction. The two-token source column
mirrors the actual `Tag.source` strings emitted at runtime.

* test(tools-pr): pin both emission paths of unresolved-changes-requested

lefarcen flagged the fallback was validated only by live-PR examples in
the PR body, so a refactor could silently regress the coverage.

Add a deterministic test file `tests/tags-unresolved-cr.test.ts` that
exercises `classifyPr` against crafted `PrFacts` fixtures:

- primary path (per-reviewer CR after reduction) fires with
  source=gh.latestReviews[].state and surfaces the reviewer login
- fallback path fires with source=gh.reviewDecision when no per-reviewer
  CR survives reduction (covers both the COMMENTED-follow-up shape and
  the empty-reviews shape — the latter pins the `reviews(last: 30)`
  out-of-window concern from the factual-reason fix)
- primary wins over fallback when both signals are present (single tag
  emitted, source=gh.latestReviews[].state)
- two negative cases: empty reviewDecision and APPROVED — neither emits

Also extend the fallback's code comment with the observed scale (3 of 102
open PRs hit this gap: #1101, #1127, #1163) so future maintainers can
tell this is a recurring queue pattern, not a theoretical edge case.

This is the first test under `tools/pr/tests/`; the package test script
already ran `node --import tsx --test tests/*.test.ts` against an empty
glob, so no scaffolding changes are needed.
2026-05-12 09:40:50 +08:00
PerishFire
c3d41c7d45
fix(tools-pr): chunk stats fetch through cursor-paginated GraphQL (#1285)
`fetchOpenPrs` was reading the stats chunk via
`gh pr list --limit 1000 --json mergeStateStatus,...`. With the default
limit raised to 1000 in #1259, this 502s reliably on the live open
queue (107 PRs): GitHub's GraphQL gateway has to recompute
mergeStateStatus for every PR up front, and the resulting query exceeds
the gateway budget once the requested page passes ~60 PRs.

Switch the stats chunk to `fetchPaginatedPrList`, the same cursor-
paginated GraphQL helper that already drives reviews / comments /
commits / assignment-timelines. Page size stays at PR_LIST_PAGE_SIZE
(30), well within the gateway budget, and the heavy stats fetch is now
consistent with the other heavy chunks.

Verified locally: `pnpm tools-pr list` now completes against the live
107-PR queue without a 502.
2026-05-11 20:51:29 +08:00
PerishFire
8c0fb8dc01
feat(tools-pr): add maintainer PR-duty workspace (#1259)
* feat(tools-pr): add maintainer PR-duty workspace

Adds `tools/pr` as the maintainer-only control plane for PR-duty work on
this repo. Thin `gh` wrapper that encodes repo-specific knowledge:
review lanes, forbidden surfaces, lane-specific checklists, validation
command derivation from touched packages.

Subcommands:
- `list` — triage open queue by lane and review-state bucket.
- `view <num>` — agent-friendly review brief for a single PR.
- `classify [num]` — emit script-level tags for one PR or the whole
  open queue; full-queue JSON output lands under `.tmp/tools-pr/classify/`
  with rate-limit telemetry per run.
- `assignment` — assigner-perspective view of PR ownership, idle time,
  and blockers (derived from existing tags; no new judgments).

Tag dictionary (13 tags) covers: bot-only-approval, needs-rebase,
forbidden-surface, unlabeled, duplicate-title, non-ascii-slug,
maintainer-edits-disabled, org-member, unresolved-changes-requested,
stale-approval, and three awaiting-* timing tags. Each rule is
expressible as one factual sentence over `gh` data + repo paths — see
`tools/pr/AGENTS.md` for the full dictionary plus precision rules.

Templates in `tools/pr/templates/*.md` are aesthetic references for
recurring maintainer comments (duplicate-title ask, awaiting-author
nudge, agent-review brief shape). `templates/examples/` holds
frozen-in-time agent-review snapshots for three PR shapes.

Infrastructure:
- `gh()` wraps `execFile` with minimum-touch retry (2 attempts at 1s + 2s
  backoff) on transient 5xx / network errors. Persistent failures still
  surface — retry is anti-jitter, not an exponential-backoff resilience
  layer.
- Heavy chunks (`reviews`, `comments`, `commits`, assignment timelines)
  use cursor-paginated `gh api graphql` via `fetchPaginatedPrList` to
  stay under GitHub's GraphQL server-side timeout. Light chunks stay on
  `gh pr list --json`.
- `fetchOrgMembers` cached per process via `gh api orgs/<owner>/members
  --paginate`.

Wiring:
- Root `package.json` adds `pnpm tools-pr` to the allowed root entry
  points.
- `scripts/postinstall.mjs` builds `tools/pr` alongside other workspace
  packages.
- `scripts/guard.ts` allowlists `tools/pr/bin/tools-pr.mjs` and
  `tools/pr/esbuild.config.mjs`, and adds `pr/` to the `tools/` top-level
  layout allowlist.
- Root `AGENTS.md` and `tools/AGENTS.md` document the new command
  surface, root-command-boundary update, and per-tool ownership.

* docs(agents): brief tools-pr in root AGENTS.md, link to tools/pr/AGENTS.md

Adds a `PR-duty tooling` section to the root AGENTS.md summarising what
`pnpm tools-pr` is, listing the four common subcommands (list / view /
classify / assignment), and pointing readers to `tools/pr/AGENTS.md` for
the full tag dictionary, operational playbook, templates, and design
rules. The section keeps root-level guidance to high-level orientation
while details stay local to the tool's own AGENTS.md.

* fix(tools-pr): drop overly broad touches-root-package.json forbidden hit

`deriveForbidden` was flagging any change to root `package.json` as a
forbidden-surface hit, but AGENTS.md §Root command boundary only forbids
specific *lifecycle* aliases (pnpm dev / test / build / daemon / preview
/ start) — tools-control-plane entrypoints like `pnpm tools-pr` are
explicitly allowed. Distinguishing "forbidden alias" from "allowed
entry" requires reading the diff content, which is `pnpm guard`'s job
rather than a path-derived classify tag.

Dogfooded on this branch's own PR (#1259), which added the `pnpm
tools-pr` script and was incorrectly flagged. Removing the hit aligns
the `forbidden-surface` tag with what tools-pr can mechanically detect
from file paths alone (apps/nextjs/, packages/shared/).

* fix(tools-pr): paginate commits fetch, recognise ready-to-merge, escape title-index separator

Three review follow-ups on #1259, all factual fixes:

- `fetchOpenPrCommits` now uses `fetchPaginatedPrList` instead of a
  one-shot `pullRequests(first: $first)` query. GitHub GraphQL caps
  connection page size at 100, so the previous implementation would
  fail at runtime when callers passed `--limit > 100`. The paginated
  path makes the commits fetch consistent with the other heavy chunks
  (reviews, comments, assignment timelines) and removes the artificial
  ceiling entirely. The `limit` parameter is dropped from
  `fetchOpenPrCommits`; the CLI `--limit` continues to bound the
  `gh pr list --json` chunks.
- `deriveStatus` in `assignment.ts` now reads `facts.reviewDecision`
  and `facts.mergeStateStatus`. When the PR is `APPROVED` with merge
  state `CLEAN` or `UNSTABLE` and carries no blockers, status renders
  as `ready to merge` instead of falling through to `in review`. The
  assignment view loses its main triage signal without this — a clean
  human-approved PR rendered identical to a REVIEW_REQUIRED one.
- `tags.ts:tagDuplicateTitle` and `tags.ts:buildContext` both
  constructed the title-index key with a literal NUL byte between
  author and title, which made the file appear as binary in `git diff`
  / review tooling. Replaced the literal byte with a Unicode escape
  sequence in source; the runtime string value is identical, the
  source stays plain text and round-trips through review tooling
  cleanly.

* fix(tools-pr): raise default --limit to 1000 to cover the live open queue

mrcfps flagged that `tools-pr list` (and `classify --all`, `assignment`)
defaults to `--limit 100`, which silently drops every PR past the first
100 in the open queue. The repo currently sits at 104 open PRs, so the
out-of-the-box run was already omitting four PRs.

Raise the default to 1000 in `list.ts`, `classify.ts`, and `assignment.ts`,
and remove the now-pointless 200 ceiling — `gh pr list --limit N` paginates
internally, so a high cap is cheap. Users can still pass `--limit <small>`
for a truncated preview. CLI help text on the three subcommands updated to
match.

* fix(web): pass designTemplates to ProjectView render helper

#955 made `designTemplates` a required Prop on ProjectView, but the test
helper added in #1244 (`renderProjectView` in
`ProjectView.api-empty-response.test.tsx`) was never updated. The two
PRs landed on main without conflicting, leaving `apps/web` typecheck red
for every PR that rebases past b5eb8c16.

Pass `designTemplates={[] as SkillSummary[]}` alongside the existing
`skills={[] as SkillSummary[]}` so the helper compiles. The component
already treats the array shape (empty included) as a no-op fallback in
the empty-response paths the test exercises.

* fix(tools-pr): correct author signal + merge inline review comments

Two correctness gaps in the awaiting-* signal pipeline surfaced during
review of the new tools-pr commands:

1. `authorSignalAt` iterated every PR commit unconditionally. On
   `maintainerCanModify=true` PRs a maintainer's follow-up push would
   advance the author timestamp, masking a stalled author response.
   Filter commits to those whose `authorLogin` matches `facts.author`,
   mirroring the same filter already applied to comments.

2. `fetchOpenPrComments` (and `fetchView`) only fetched
   `pullRequest.comments` / `gh pr view --json comments`, which is the
   issue-conversation thread. Inline review-thread replies — where
   authors and reviewers actually exchange most fix-up replies — live in
   `reviewThreads.comments` / REST `pulls/{n}/comments`. Missing them let
   `humanReviewerSignalAt` / `authorSignalAt` and the `view` brief point
   at the wrong side after someone replied inline. Extend the list-mode
   GraphQL to also sweep `reviewThreads(last: 20).comments(first: 20)`,
   and add a parallel REST inline-comments fetch in `fetchView` that
   merges into `GhView.comments`.
2026-05-11 19:17:21 +08:00
Tom Huang
b5eb8c1647
feat: generic skills + split skills/design-templates + finalize-design API (#955)
* feat: general-purpose skills with @-mention composition and user import

Lift skills from "one mode-bound skill per project" to a generic capability
the user can compose per turn:

- Daemon: scan multiple skill roots (user-skills under runtime data, then
  the bundled `skills/`); user-imported skills can shadow built-ins by id.
- New `POST /api/skills/import` and `DELETE /api/skills/:id` endpoints,
  with CONFLICT/BAD_REQUEST/NOT_FOUND error codes and built-in delete
  protection.
- ChatRequest gains `skillIds: string[]`; the chat run concatenates each
  picked skill's body (and merges craftRequires) into the system prompt
  for that turn only — the project's persistent `skillId` is untouched.
- Web composer: `@` popover now lists skills alongside project files;
  picks render as removable chips above the textarea and ride along with
  the request as `skillIds`.
- Settings → Library: import form (name/description/triggers/body),
  per-card delete for user skills, "user" origin badge.

* chore(web): drop welcome pet teaser + add ds→prompt-template mapping util

- SettingsDialog: remove the inline pet adoption teaser from the welcome
  panel so the first-run modal stays focused on configuration.
- New `inferPromptTemplateCategoriesForDs(ds)` helper that maps a design
  system's authored metadata to prompt-template gallery categories.
  Imported by the design-system gallery wiring on a sibling branch; no
  callers in this branch yet.

* feat: split skills/design-templates and add finalize-design API

Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):

- Move ~104 rendering catalogue entries from skills/ to design-templates/
  and keep skills/ for the small set of functional skills that *do work*
  on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
  contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
  an /api/design-templates surface mirroring /api/skills. Asset/example
  routes still span both registries so existing srcdoc URLs keep
  resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
  rename the EntryView "Examples" tab to "Templates", and update locales
  + the New-project picker accordingly.

Adds the finalize-design endpoint:

- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
  finalize.ts — one-shot synthesis of a project's transcript + active
  design system + current artifact into <projectDir>/DESIGN.md via the
  Anthropic Messages API. Per-project .finalize.lock mirrors the
  transcript-export hygiene from PR #493; provider credentials are not
  persisted by the daemon.

Other supporting changes:

- README + AGENTS.md updates to document the new directory split and
  craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
  server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
  vendor binaries are no longer hidden.

* fix(merge): move clinical-case-report to design-templates/

Origin/main added the clinical-case-report skill under skills/ before
the skills/design-templates split landed. Its od.mode is prototype, so
per specs/current/skills-and-design-templates.md it is a design template
and belongs alongside the other rendering catalogue entries — not under
the slimmed-down functional skills/ root. Moving it keeps the EntryView
Templates tab consistent with origin/main's intent.

* feat(skills): curated design/creative catalogue + collapsible Settings rows

Seed ~100 curated design/creative skill stubs under skills/ sourced from
awesome-claude-skills (ComposioHQ) and awesome-agent-skills (VoltAgent).
Each stub carries an od.category tag so the new filter pill row in
Settings -> Skills can group them. The seed script
(scripts/seed-curated-design-skills.ts, pnpm seed:curated-design-skills)
is idempotent: it only creates folders that don't already exist, so
hand-edited stubs are never overwritten.

- Daemon: parse and surface od.category on SkillInfo with a strict slug
  normaliser; mirror the field on SkillSummary in @open-design/contracts.
  Category is purely a UI hint — system-prompt composition is unchanged.
- Web: rewrite SkillsSection from a left-list / right-detail grid into a
  vertical stack of collapsible rows mirroring the External MCP panel
  (header always visible with name + mode/source/category pills + per-row
  enable toggle; SKILL.md preview, file tree and inline edit form expand
  on demand). Add a Category filter row above the list. Reorder Settings
  nav so Skills + External MCP sit above the Composio/MCP cluster. Update
  composer placeholder/hint across 17 locales to advertise '@ files or
  skills · / for commands'.
- Docs: extend skills/AGENTS.md with the curated catalogue rules
  (idempotency, category vocabulary, no upstream vendoring).

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(skills): teach localized-content + system-prompt tests about the skills/design-templates split

mrcfps blocking review on PR #955: the skills/design-templates split
(b5993385) moved ~110 SKILL.md entries out of `skills/` and into
`design-templates/`, but two repo-level tests still hard-coded the
single-root layout, so CI gates went red on the merged branch:

- `e2e/tests/localized-content.test.ts` only scanned `<repo>/skills`
  while the locale `skillCopy` map keeps id-keyed entries spanning
  both roots (ExamplesTab/Templates uses one lookup regardless of
  origin). Teach the helper to read both `skills/` and
  `design-templates/`, deduplicating ids so the union matches the
  localized claim.
- `apps/daemon/tests/prompts/system.test.ts` read
  `skills/live-artifact/SKILL.md`, which now lives under
  `design-templates/live-artifact/`. Update the absolute path so
  composeSystemPrompt's coverage of the live-artifact preamble is
  exercised again.

Also enroll the curated design/creative catalogue (PR #955, ~91
stubs sourced from awesome-claude-skills / awesome-agent-skills) in
the DE / FR / RU `_SKILL_IDS_WITH_EN_FALLBACK` lists. The stubs are
English-only by design (frontmatter advertises an upstream URL); the
fallback list is exactly the place to acknowledge "we know this id
exists, English copy is fine here" so the localized-content coverage
gate passes without forcing a translation task per locale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): always quote frontmatter name so importUserSkill round-trips numeric / boolean ids

mrcfps PR #955 review: `buildSkillMarkdown` emitted `name:
${escapeYamlString(name)}` without quotes, so YAML coerced names
like `123`, `true`, `false`, or `null` into non-string scalars on
re-parse. listSkills() then read `data.name` as a number/boolean
and the import flow's follow-up `findSkillById(skills, result.id)`
missed it, falling into `/api/skills/import`'s "imported skill
could not be re-read" 500 path for those ids.

Switch the emitter to a quoted scalar (`name: "..."`) — the
double-escape already in `escapeYamlString` makes the quoted form
safe — and add a round-trip test covering `123`, `true`, `false`,
`null`, and `0` to lock in the contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop staged-skill chips when the matching @<id> token leaves the draft

mrcfps PR #955 review: `submit()` always forwarded every id in
`stagedSkills`, but that state was only mutated on picker click and
chip removal. Hand-deleting an `@<id>` token from the textarea left
the chip staged, so the request still carried `skillIds: [<id>]` and
the daemon composed a skill the prompt no longer referenced.

Sync the chips with the draft inside `handleChange()` by pruning
`stagedSkills` whenever the new value no longer contains the
`@<id>` token (using the same whitespace boundary as
`removeStagedSkill`'s strip regex). Comment explains why this
prune does not run for `staged` file attachments — users frequently
add files via the upload button without leaving an `@<path>` token,
so a symmetric prune there would erase legitimate uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): stage @-composed skills' side files alongside the active skill

codex PR #955 review: composing a per-turn `@`-picked skill into the
system prompt appended its body (with the `withSkillRootPreamble`
guidance pointing at relative paths under `<cwd>/.od-skills/<folder>/`)
but never staged the actual folder. `startChatRun` only copied
`activeSkillDir`, so when the project's primary skill was different
(or absent) the composed skill's references/, examples/, and scripts/
files lived only at their absolute repo path — agents that honour
the cwd-relative form (or that don't get `--add-dir`, e.g. Codex with
allowlisted gpt-image projects) couldn't reach them.

Thread the composed skills' dirs out of `composeDaemonSystemPrompt`
as `extraSkillDirs` and stage each one through the same
`stageActiveSkill` API used for the primary skill. Dedupe by folder
basename so a project whose primary skill is also `@`-composed isn't
copied twice. Each preamble already advertises its own folder, so the
prompt and the staged tree stay aligned without further changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): respect the Library disable toggle in the project @-mention picker

codex PR #955 review: only `EntryView` received `enabledSkills`
(filtered against `config.disabledSkills`); active projects still
got `skills={skills}` raw, so a skill the user disabled in Settings
kept appearing in the project's `@`-mention popover and could ride
along to the daemon via `skillIds`. That broke the Library toggle
for any project opened on the post-split branch.

Compute a functional-skills-only enabled subset
(`enabledFunctionalSkills`) and pass it into `<ProjectView>` instead.
Templates stay separate — design-templates are filtered through their
own `enabledDesignTemplates` memo for the Templates gallery — so
ProjectView's chat composer still only sees skills, never templates,
matching the pre-split prop surface.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): mock /api/design-templates for example-use-prompt flow

The Templates tab in EntryView fetches from /api/design-templates after
the skills/design-templates split (specs/current/skills-and-design-templates.md).
The example-use-prompt Playwright scenario only mocked /api/skills, so the
gallery card never appeared and the test timed out waiting on
example-card-warm-utility-example. Serve the same fixture summary on both
endpoints so the templates gallery renders the card the test clicks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(tools-pack): create design-templates fixture for resources test

The packaging resources copy now bundles the new design-templates tree
alongside skills (see resources.ts BUNDLED_RESOURCE_TREES). The
copyBundledResourceTrees fixture only created skills, design-systems,
craft, etc., so the recursive copy crashed with ENOENT on
design-templates before it could check the prompt-templates assertion.
Add the missing fixture directory so the test exercises the same set
of resource trees the packaged build does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): clone built-in side files into the shadow on first edit

mrcfps PR #955 review: editing a built-in skill wrote a USER_SKILLS_DIR
shadow folder that contained only a new SKILL.md. The next listSkills()
pass surfaced the shadow as the active dir, but every side-file resolver
(/api/skills/:id/files, /example, /assets/*, the system-prompt preamble,
and the per-turn cwd staging) reads through skill.dir. With nothing but
SKILL.md in the shadow, the bundled assets/, references/, scripts/, and
examples/ disappeared the moment the user hit save — a built-in like
last30days or live-artifact would break immediately after edit instead
of just having its body overridden.

Teach updateUserSkill() to take a `sourceDir` and clone every entry
except SKILL.md / dotfiles into the shadow on the very first edit. The
shadow stays self-contained, so all the resolvers keep working without
fallback bookkeeping. Subsequent edits detect the existing shadow and
skip the clone, so user tweaks under the side tree survive a re-save.

Wire `sourceDir: skill.dir` from server.ts's PUT /api/skills/:id handler
and add two regression tests:
- 'clones built-in side files into the shadow on the first edit' walks
  the file tree after save and asserts assets/template.html, references/
  notes.md, and scripts/helper.sh all round-trip from the built-in.
- 'preserves user-edited side files on subsequent edits' edits the
  staged assets/template.html, re-saves, and confirms the user content
  is still there.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): rename home tab from Examples to Templates

The Examples tab was renamed to Templates in EntryView (b5993385's
skills/design-templates split — entry.tabExamples became entry.tabTemplates
and the tab value moved from 'examples' to 'templates'), but
entry-chrome-flows still asserted the old label and testId. Update both.

* fix(skills+web): preserve template body in API mode and dir-based skill delete

Two follow-ups from PR #955 review:

1. ProjectView only received `enabledFunctionalSkills`, but
   `composedSystemPrompt()` still resolved `project.skillId` through that
   prop and `fetchSkill()`. Projects created from the new
   `/api/design-templates` surface keep a template id in `project.skillId`,
   so opening one in API mode dropped the template body from the system
   prompt and the upstream request ran without the project's primary
   template instructions. Now ProjectView takes a separate
   `designTemplates` prop (the unfiltered template list, so a
   later-disabled template still loads for projects already created from
   it) and `composedSystemPrompt()` plus the metadata / `isDeck` lookups
   fall back to that list, with `fetchDesignTemplate()` as the body-fetch
   fallback to `fetchSkill()`. The chat composer's `@`-picker keeps
   receiving only the enabled functional skills.

2. `DELETE /api/skills/:id` used `deleteUserSkill(USER_SKILLS_DIR, skill.id)`
   which re-slugified the frontmatter id and removed
   `<userSkillsDir>/<slug>/`. That matched the import shape but missed the
   install shape — `installFromTarget` writes the folder at
   `sanitizeRepoName(url)` (GitHub) or `path.basename(realpath)` (local
   symlink), neither of which is guaranteed to equal the slugified
   frontmatter `name`. A duplicate `app.delete('/api/skills/:id', ...)`
   handler at the install routes never fired because Express resolved the
   earlier registration first, leaving the install/uninstall path without
   working teardown. The handler now removes `skill.dir` (the absolute
   path listSkills already discovered) under a USER_SKILLS_DIR safety
   check, using `lstat` + `unlinkSync` so symlinked local installs unlink
   cleanly without recursing into the user's source tree. The dead
   duplicate handler is removed; `deleteUserSkill` is dropped from the
   server.ts import set (still exported and unit-tested in skills.ts).
   Regression coverage in `apps/daemon/tests/skills-delete-route.test.ts`
   pins both shapes plus the symlink-preserves-source case.

* test(daemon): point hyperframes system-prompt test at design-templates

The merge with main brought in a hyperframes system-prompt test that
reads `skills/hyperframes/SKILL.md`, but this branch's split moved
`hyperframes` into `design-templates/` (same migration as `live-artifact`
already handled above in this file). CI was failing with ENOENT on the
old path.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:48:34 +08:00
PerishFire
421ddf553c
fix(pack/win): close running app before silent reinstall (#1238) 2026-05-11 16:35:07 +08:00
Bryan A
587c783dc0
feat(web): add Finalize design package + Continue in CLI buttons (#451) (#974)
* feat(daemon): expose resolvedDir on GET /api/projects/:id (#451 prereq)

Native projects (no metadata.baseDir) live at <projects root>/<id>, where
projects root is daemon-side state. The web client cannot reconstruct an
absolute path on its own, and shell.openPath on a relative path is
undefined behavior. Without resolvedDir, the upcoming Continue in CLI
button (#451) would render permanently disabled for native projects.

Mirrors PR #832's pattern of exposing designMdPath in its response.
Computed via the existing resolveProjectDir(...) helper. No behavior
change to existing callers; they ignore the new field.

Adds ProjectDetailResponse contract type and a focused projects-routes
test covering imported-folder, native, and unknown-id paths.

* feat(web): add parseProvenance helper for DESIGN.md staleness checks

Pure helper that extracts Project ID, design system, current artifact,
transcript message count, and generated UTC timestamp from the
`## Provenance` section emitted by the daemon's finalize synthesis
prompt (apps/daemon/src/finalize-design.ts). Used by useDesignMdState
to derive the Continue in CLI button's stale/fresh state without an
additional daemon endpoint.

Handles missing section, "none" sentinels for design system /
artifact, and malformed timestamps without throwing. Tests cover all
four branches.

* feat(web): add buildClipboardPrompt template for Continue in CLI

Inline single-source-of-truth template per #451 spec §3.4. Names the
project, the working directory, and the DESIGN.md-first operating
contract for the receiving `claude` CLI session. Trailing TODO is
the blank task slot the issue body specifies — left empty so the user
fills it in before submitting.

Also lands the shared copyToClipboard helper (jsdom-safe canonical path
+ execCommand fallback) so the new button and any future caller share
one fallback path, mirroring the inline pattern in FileViewer.tsx.

Tests cover happy-path field rendering, "none"/"unknown" sentinels
when DESIGN.md fields are absent, and both clipboard branches.

* feat(web): add useProjectDetail + useDesignMdState hooks

useProjectDetail wraps GET /api/projects/:id, surfacing the resolvedDir
field and falling back to metadata.baseDir for older daemons that don't
include it. Continue in CLI needs an absolute working directory so the
desktop bridge can openPath it; the web client never reconstructs the
path itself.

useDesignMdState fetches the project's file list, downloads DESIGN.md
when present, parses the Provenance section, and computes a stale
verdict by comparing the recorded generatedAt against the max mtime of
non-DESIGN.md files and the max conversation updatedAt. Drives the
button's three-state UI (disabled / fresh / stale) without a
daemon-side endpoint.

Tests cover happy path, fallback, and both stale branches plus the
pure computeStale helper for the null-timestamp edge case.

* feat(web): add useFinalizeProject hook with cancel + error-code mapping

Wraps POST /api/projects/:id/finalize/anthropic for the Finalize design
package button. Three concerns:

  1. Lifecycle: idle → pending → success | error. Double-clicking the
     button aborts the prior in-flight request before starting a new
     one so the daemon never sees stacked finalize calls per project.

  2. Cancellation: AbortController plumbed through fetch + a 130 s
     timer (daemon timeout 120 s + 10 s buffer). Cancel returns to idle
     cleanly — it's a user gesture, not an error surface.

  3. Daemon error mapping: when the response is non-OK, body.error.code
     drives the canonical user-facing toast string (table covers all
     7 codes the daemon emits today plus a network-error catch-all).
     body.error.details, when a string, surfaces alongside the category
     message so account-usage-cap responses (Anthropic 400 →
     UPSTREAM_UNAVAILABLE) can show the upstream's own reason instead
     of just the daemon's category label — committed to lefarcen on
     #450 verification reply.

Tests cover request body shape, all 8 error codes via it.each, the
network-error path, the details-surfacing branch, the cancel ⇒ idle
flow, and the unknown-code → catch-all message branch.

* feat(web): add useTerminalLaunch with electron/web detection

Capability-detected wrapper around window.electronAPI.openPath. On
desktop the bridge forwards to shell.openPath, which opens the OS
file manager at the project working directory (per Electron's
contract for directory paths — it is NOT a terminal launcher;
spawning a terminal application is deferred per #451 Non-goals). On
browser builds the hook reports web-fallback so the caller renders
a manual-instruction toast naming the working directory.

Treats any non-empty string return from shell.openPath as ok: false
so platform-specific failures surface the manual fallback toast.
Behavior is exercised end-to-end by the upcoming
ContinueInCliButton tests.

* feat(desktop): expose shell.openPath via electronAPI bridge

Adds an openPath bridge method that the Continue in CLI button (#451)
uses to surface the project working directory in the OS file manager.
shell.openPath is part of Electron's contract and resolves to '' on
success / a non-empty error string on failure; the IPC handler
forwards the result so the renderer can decide between the success
toast and the manual fallback toast without a separate error channel.

Empty / non-string inputs short-circuit to a self-describing error
string so the renderer never needs to worry about undefined-input
crashes from the main process.

Web side: extracts Window.electronAPI into a single global declaration
at apps/web/src/types/electron.d.ts so future bridge methods land in
one place. Two pre-existing inline declare-global blocks
(NewProjectPanel.tsx, providers/registry.ts) are deleted in favor of
that single source of truth — the inline ones each carried a partial
shape of the bridge and were diverging from the desktop preload.

* feat(web): add FinalizeDesignButton, ContinueInCliButton, ProjectActionsToolbar

Project-level toolbar that hosts the two new actions from #451.
Mounted between AppChromeHeader and the chat/workspace split (wiring
lands in the next commit). Per-file actions (Export PDF/PPTX/ZIP,
Deploy) stay in the FileViewer share menu.

FinalizeDesignButton has three idle labels driven by DESIGN.md
existence + staleness, plus a pending state with a spinner and a
cancel link that maps to useFinalizeProject's AbortController. Error
toasts are owned by ProjectView so the button doesn't carry its own
toast surface.

ContinueInCliButton renders disabled with a Finalize-pointing
tooltip when DESIGN.md is missing (so the workflow is discoverable
rather than hidden), enabled when fresh, and enabled with a stale
chip otherwise. Chip text is the spec's canonical "Spec is stale —
regenerate?" — N-turns-ago is deferred per spec §4.6.

Toast.tsx is a tiny transient component that mirrors
PromptTemplatePreviewModal's state-based toast pattern; supports a
secondary details line so daemon error envelopes that carry an
upstream explanation (e.g. Anthropic account-usage cap) can surface
the real reason alongside the daemon's category label.

CSS appends one block to apps/web/src/index.css mirroring the
existing app-project-title token usage; no CSS modules in this
repo (verified by grep).

* test(web): cover ContinueInCliButton states + interaction wiring

Three rendered states (DESIGN.md missing → disabled with the
Finalize-pointing tooltip; DESIGN.md fresh → enabled, no chip;
DESIGN.md stale → enabled with the canonical "Spec is stale —
regenerate?" chip), plus three onClick branches (no-op when
disabled, fires once when fresh, fires once when stale).

Click-handler integration with clipboard / shell.openPath / toast
lives in ProjectView (the button is presentational and takes the
handler in via props), so those are covered by Phase K's wiring +
the manual smoke test rather than the per-component test.

* feat(web): wire Continue in CLI + Finalize buttons into ProjectView

Mounts the new project-actions toolbar between AppChromeHeader and
the chat/workspace split, hidden when workspaceFocused so the
focus-mode artifact view stays uncluttered.

Wires the four hooks (useProjectDetail, useDesignMdState,
useFinalizeProject, useTerminalLaunch) to a single shared toast
surface. handleFinalize reads the request body from the existing
config: AppConfig prop and uses effectiveMaxTokens(config) to match
the chat-flow's maxTokens defaulting; on success it refreshes
useDesignMdState so the toolbar re-renders with the new chip state.

handleContinueInCli builds the literal clipboard prompt, copies it,
opens the working directory via shell.openPath on desktop /
falls through to a manual-instruction toast on browser, and surfaces
shell.openPath failures with a fallback toast that names the path.

Errors lift into the same toast surface (a useEffect tied to
finalize.error) so the daemon's category message + body.error.details
reach the user as the spec's two-line render — covered by hook test
16a in the prior commit.

⌘+Shift+K (mac) / Ctrl+Shift+K (others) is the keyboard
accelerator for Continue in CLI; capture-phase, platform-gated,
no-op when DESIGN.md is missing. Mirrors the existing FileWorkspace
shortcut idiom and does not collide with ⌘+P (Quick Switcher).

* fix(web): distinguish timeout abort from user cancel in useFinalizeProject

Addresses codex P2 finding on PR #974: the catch block treated every
AbortError as a user-initiated cancel and reset to idle silently. If
the internal 130 s timeout fired, users saw no failure signal but the
daemon's synthesis call may still have been in flight.

Adds a timedOutRef set inside the setTimeout callback before
controller.abort(), and branches in the catch: timeout → status
'error' with new TIMEOUT code ("Finalize timed out after 130 s. The
daemon may still be running."), user cancel → existing idle reset.
Reset the ref at the start of every trigger() so a previous timeout
doesn't poison the next call.

Adds one test using vi.useFakeTimers() that advances past 130_001 ms
and asserts the TIMEOUT error surface.

* fix(web): surface clipboard failures by rendering the prompt in the toast

Addresses codex P2 finding on PR #974: handleContinueInCli ignored
copyToClipboard's return value, so when both clipboard paths failed
(restricted browser context / insecure origin) the toast still said
"paste the prompt" though nothing had been copied — leaving users
with no manual-copy recourse in exactly the environments where the
fallback should help.

handleContinueInCli now branches on copyToClipboard's boolean return.
On failure the toast renders the prepared prompt in a scrollable
<pre> block and pins itself open (no auto-dismiss) so the user has
time to select-and-copy manually. Includes a Dismiss button + the
working directory in the secondary details line so the user has the
information needed to proceed.

The folder-open call is skipped on copy failure because there's
nothing to paste yet; the user copies first, then re-clicks Continue
in CLI when they're ready.

Toast component grows an optional Updating VS Code Server to version 41dd792b5e652393e7787322889ed5fdc58bd75b
Removing previous installation...
Installing VS Code Server for Linux x64 (41dd792b5e652393e7787322889ed5fdc58bd75b)
Downloading:       0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99%100%100%
Unpacking:   0%  1%  2%  3%  4%  5%  6%  7%  8%  9% 10% 11% 12% 13% 14% 15% 16% 17% 18% 19% 20% 21% 22% 23% 24% 25% 26% 27% 28% 29% 30% 31% 32% 33% 34% 35% 36% 37% 38% 39% 40% 41% 42% 43% 44% 45% 46% 47% 48% 49% 50% 51% 52% 53% 54% 55% 56% 57% 58% 59% 60% 61% 62% 63% 64% 65% 66% 67% 68% 69% 70% 71% 72% 73% 74% 75% 76% 77% 78% 79% 80% 81% 82% 83% 84% 85% 86% 87% 88% 89% 90% 91% 92% 93% 94% 95% 96% 97% 98% 99%100%
Unpacked 4009 files and folders to /home/bryan/.vscode-server/bin/41dd792b5e652393e7787322889ed5fdc58bd75b.
Looking for compatibility check script at /home/bryan/.vscode-server/bin/41dd792b5e652393e7787322889ed5fdc58bd75b/bin/helpers/check-requirements.sh
Running compatibility check script
Compatibility check successful (0) prop and the auto-dismiss
TTL is suppressed whenever code is present. CSS adds .od-toast-code
(monospace, max-height 240 with overflow-auto) and .od-toast-dismiss
styling.

Six new Toast tests cover details rendering, code rendering,
no-auto-dismiss when code is present, auto-dismiss when code is
absent, and the Dismiss button affordance.

* fix(web): make ContinueInCliButton disabled-state guidance visible

Addresses mrcfps's PR #974 review: native <button disabled> does
not fire hover/focus events in browsers we ship against, so a
`title` tooltip on the disabled button never surfaces. The only
guidance for the missing-DESIGN.md state was effectively invisible —
defeating the spec's "discoverable, not hidden" intent.

Renders the help text as a visible sibling <span> next to the
disabled button instead. Adds aria-describedby pointing the button
at the hint's id so assistive tech announces the explanation when
the disabled button gets focus. The native `disabled` attribute
stays so the button still can't be clicked or submitted.

CSS adds .project-actions-disabled-hint (muted italic, 11.5px,
matches the existing meta/secondary text style on this surface).

Test asserts the role="note" hint is in the DOM with the canonical
text and that the button's aria-describedby links to its id.

* fix(web): keep ProjectActionsToolbar at natural height inside the .app grid

The .app container was `grid-template-rows: auto 1fr` — only two
rows. Adding ProjectActionsToolbar as a third child between
AppChromeHeader and the chat/workspace split made the toolbar the
2nd grid item, so it took the `1fr` row (filling roughly half the
viewport) while the split got pushed into an implicit auto row at
its content's natural height. Surfaced as a screenshot from Bryan
showing the toolbar's background bleeding across most of the screen.

Extend grid-template-rows to `auto auto 1fr` and pin the split to
`grid-row: 3` explicitly. Now:
- Toolbar visible: row 1 = header (auto), row 2 = toolbar (auto),
  row 3 = split (1fr, fills remaining viewport).
- Toolbar hidden via hidden=workspaceFocused → ProjectActionsToolbar
  returns null, row 2 collapses to 0px (auto with no content), split
  still fills row 3.

No JS changes; existing 609 tests still green.

* fix(web): guard useFinalizeProject state writes against superseded triggers

Addresses mrcfps's PR #974 P1 review on useFinalizeProject.ts:132
(also called out as P1.3 in lefarcen's deep-dive review).

Calling trigger() twice in quick succession aborted the first
controller and swapped abortRef to the new one, but the first
request's later AbortError catch still unconditionally called
setStatus('idle') / setError(null). That cleared the spinner and
re-enabled both toolbar buttons while the replacement finalize was
still pending — defeating the de-duplication this hook was meant to
enforce.

Adds an isCurrent() closure (`abortRef.current === controller`)
and gates every state-write site after the await: success path,
non-OK envelope path, AbortError-timeout, AbortError-cancel, and
network-error all bail early when the trigger has been superseded.
Per mrcfps: "make every state write request-scoped."

Regression test triggers twice in quick succession with a
never-resolving fetch, awaits the first promise (it rejects with
AbortError), and asserts status stays 'pending' rather than
collapsing to 'idle' under the replacement's lifetime.

* fix(desktop): allowlist-validate shell.openPath against registered project roots

Addresses mrcfps's PR #974 P1 review on runtime.ts:305 (also called
out as P1.2 in lefarcen's deep-dive review): the new
`shell:open-path` IPC handler accepted any renderer-supplied
string and forwarded it straight into Electron's `shell.openPath`,
widening the renderer→main trust boundary so XSS or a compromised
renderer dependency could open arbitrary local paths to the user.

Adds an explicit gate around the bridge:

  1. validateExistingDirectory(p) — floor check that rejects empty
     strings, relative paths, files, apps, and non-existent paths;
     realpath-resolves so symlink games can't be used to register
     one path and reach another.

  2. createProjectRootGate() — Set-backed allowlist of
     daemon-validated project working directories. The renderer
     calls registerProjectRoot(absDir) once per project mount via
     a new IPC method (preload bridge); the main process only
     opens paths that pass both the floor check and the allowlist.

ProjectView wires the registration via a useEffect tied to
projectDetail.resolvedDir, so the active project's daemon-supplied
working directory is always the one being approved (not a renderer-
synthesized string).

Threat-model caveat documented in the runtime.ts comment block: an
attacker that fully controls the renderer can also call register
with arbitrary paths. Closing that gap fully requires a daemon-side
round-trip to derive the canonical resolvedDir from the daemon's
project registry, which is deferred to keep this PR focused.
Today's allowlist still defends against accidental misuse, bugs,
and common XSS payloads that don't know to call register first.

Adds apps/packaged/tests/desktop-project-root-gate.test.ts with 13
cases: floor-validation rejection cases (empty / relative / missing
/ file), happy-path resolution, symlink realpath canonicalization,
and the allowlist's register/isApproved/reset semantics. Mirrors
the existing apps/packaged/tests/desktop-url-allowlist.test.ts
pattern from PR #911 — the packaged workspace hosts the test
because apps/desktop has no vitest setup yet.

* fix(daemon): wire request-lifecycle abort signal through finalize route

Addresses mrcfps's PR #974 P1 review on
apps/daemon/src/server.ts:3831-3837 (also called out as P1.1 in
lefarcen's deep-dive review): `POST /api/projects/:id/finalize/anthropic`
called `finalizeDesignPackage(...)` without threading any
request-lifecycle abort, so cancelling the browser fetch only
aborted the UI-side request — the daemon's 60–120 s Anthropic call
kept running and still wrote DESIGN.md after the UI returned to idle.

Adds an AbortController inside the route handler, fired from
`res.on('close')`, and threads its signal into the existing
`signal?: AbortSignal` parameter on `FinalizeOptions`
(finalize-design.ts:70). `callAnthropicWithRetry` already passes
the signal through to the underlying fetch, so a client disconnect
now propagates all the way to the Anthropic SDK call.

Listener-event choice: `res.on('close')` is the canonical event
for "client disconnected before response was sent" in Express. The
common alternative `req.on('close')` fires whenever the *request*
stream finishes — for POST routes that means as soon as the
body-parser middleware drains the body, well before the route does
any work. Using req.on('close') would have flipped the abort
controller in every successful run; the test caught this empirically.

Caveat documented in the route's comment block: an abort fired
*after* the upstream response has been received but *before* the
atomic write completes still allows the write to land. The SDK
contract bounds the network round-trip, not the post-network disk
handoff.

Adds tests/finalize-route-abort.test.ts: spins up the test server,
mocks global fetch to capture the daemon-side AbortSignal at the
Anthropic call, sends the request via raw http (so we can destroy
the underlying socket), waits until the server reaches the
Anthropic call, then destroys the socket and asserts that the
daemon-side signal received an abort event within 5 s.

Three pre-existing project-watchers chokidar tests show flaky
timeouts under full-suite concurrency but pass in isolation;
unrelated to this fix.

* fix(daemon): refactor finalize-route-abort test to satisfy strict TS narrowing

The CI typecheck (`pnpm --filter @open-design/daemon typecheck`,
which runs both tsconfig.json and tsconfig.tests.json) caught what
my pre-push validation missed: TS narrowed `capturedSignal` to
literal `null` because vitest's mockImplementation closure can't
prove its callback runs, leaving the bare `let capturedSignal:
AbortSignal | null = null` permanently typed at its initial value.
At line 184 (`expect(capturedSignal?.aborted).toBe(true)`) the
right-hand side of the optional-chain became unreachable, and TS
flagged it as `Property 'aborted' does not exist on type 'never'`.

Switches to the standard ref-object pattern
(`const capture: { signal: AbortSignal | null } = { signal: null }`).
TS narrows let bindings inside closures conservatively but treats
object-property writes as opaque, so `capture.signal` reads
correctly across the closure boundary. Logic is unchanged.

(Pre-push oversight: ran `pnpm --filter @open-design/web typecheck`
but not the full repo `pnpm typecheck` after the daemon test
landed; the daemon's own typecheck would have caught this. Adding
`pnpm typecheck` back into the standard pre-push checklist.)

* fix(desktop): make shell.openPath gate daemon-controlled and reject .app bundles

Addresses lefarcen + mrcfps PR #974 P1 reviews on the previous path
allowlist (commit 8bf56597):

  - mrcfps (runtime.ts:45): `validateExistingDirectory` accepted
    macOS `.app` bundles because they're directories, so the gate
    would forward `/Applications/Safari.app` (or any other app
    bundle) into shell.openPath and *launch* the application — a
    stronger capability than the bridge's intended "reveal the
    project folder" feature.

  - lefarcen (runtime.ts:396): the allowlist was renderer-controlled.
    A compromised renderer could call `shell:register-project-root`
    with any existing absolute directory and then `shell:open-path`
    that same path; the IPC injection issue I'd documented as
    "deferred" was the central reviewer concern, not an acceptable
    caveat. Both reviewers asked for the gate to be derived from
    a daemon-authoritative source.

The redesign drops the renderer-controlled register/openPath pair
and replaces it with a single `openPath(projectId)` bridge call.
The desktop main process resolves the project ID by calling the
daemon's `GET /api/projects/:id` endpoint over the web sidecar
proxy (which already forwards `/api/*` to the daemon — verified
in apps/web/sidecar/server.ts:209 and apps/web/next.config.ts:77),
parses `resolvedDir` from the response, validates it against the
floor (absolute, exists, is-directory, not .app), and only then
forwards to `shell.openPath`. The renderer never names the path
directly, so a compromised renderer cannot escalate to opening
arbitrary local paths — it can only name a project the daemon
already knows about, and the canonical path comes from the daemon's
own response.

Surface changes:

  - `runtime.ts`: `createProjectRootGate` removed.
    `fetchResolvedProjectDir(webUrl, projectId, fetchImpl?)` added.
    `validateExistingDirectory` rejects `.app` suffix after the
    realpath check (so symlinked launders are caught too).
    `shell:open-path` handler signature changes from `(path)` to
    `(projectId)`; `shell:register-project-root` handler removed.

  - `preload.cts`: `openPath(projectId)`; `registerProjectRoot`
    removed from the bridge surface.

  - `apps/web/src/types/electron.d.ts`: type updated to match.

  - `useTerminalLaunch.ts`: `open(projectId)` instead of
    `open(dir)`.

  - `ProjectView.tsx`: passes `project.id` to
    `terminalLauncher.open`; the registerProjectRoot useEffect is
    deleted. Toast text still reads `projectDir` (from
    `useProjectDetail.resolvedDir`) for fallback messages — the
    *display* path is independent of the *open* mechanism.

  - `apps/packaged/tests/desktop-project-root-gate.test.ts`:
    rewritten to cover `validateExistingDirectory` (8 cases
    including the new `.app` suffix and symlinked-bundle rejection)
    and `fetchResolvedProjectDir` (8 cases including empty/invalid
    project ids, daemon HTTP success/failure, missing resolvedDir,
    network error, and URL canonicalization).

Total: 16 passing tests, ~330 LOC churn including test rewrites.

Lesson learned (from the iteration loop, not the code): when a
reviewer asks for "ideally X, or at least Y," shipping Y with a
deferred-X note flags the gap rather than fixing it. Either ship X
or argue Y is sufficient; don't middle-ground.

* feat(contracts,sidecar-proto): add desktop-auth IPC + fromTrustedPicker

Schema-only prep for the PR #974 round-3 fix. Adds the two type
extensions the daemon HTTP gate and the desktop main process will
build on:

- packages/sidecar-proto: SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH, with a
  base64-validated `{ secret }` payload + RegisterDesktopAuthResult.
  Updates normalizeDaemonSidecarMessage to accept the new message and
  pins both branches (accept + reject) in tests/index.test.ts.

- packages/contracts: ProjectMetadata.fromTrustedPicker — a marker the
  daemon stamps on folder-imported projects whose POST /api/import/folder
  passed the desktop HMAC gate. The marker is privileged in the same
  way as `baseDir`: only the gated import handler sets it, and the
  desktop main process refuses to forward `shell.openPath` for
  folder-imported projects whose metadata lacks it.

* fix(daemon): gate /api/import/folder on desktop HMAC token

Closes the renderer→arbitrary-baseDir→shell.openPath bypass chain
flagged by lefarcen and mrcfps in round 3 of PR #974. Both reviewers
converged on the same gap: the previous round only moved path
resolution into the daemon, but renderer JS could still POST
/api/import/folder with any absolute path, get a project ID back, and
then call openPath(projectId) to reveal the attacker-chosen path.

Daemon-side closure:

- New module-scope desktop auth secret + setter exported from
  apps/daemon/src/server.ts. The secret is null at boot (web/standalone
  mode unaffected) and gets set when the desktop main process
  registers it over the daemon's sidecar IPC.

- New `verifyDesktopImportToken` pure helper. Verifies tokens shaped
  `${nonce}~${exp}~${signature}` against HMAC-SHA256(secret, baseDir +
  "\n" + nonce + "\n" + exp). Field separator is `~` (not `.`) because
  ISO 8601 expiries embed dots; `~` is in neither base64url nor ISO
  8601 character sets. Rejects expired tokens, replayed nonces, and
  expiries beyond 2× the 60s TTL.

- New middleware on POST /api/import/folder. When the secret is set,
  every request must carry a valid `X-OD-Desktop-Import-Token` header
  bound to the requested baseDir. Rejected requests return 403 with
  FORBIDDEN. When the secret is unset (no desktop registered), the
  route is unchanged so web-only deployments and standalone daemons
  keep working.

- Trusted imports get `metadata.fromTrustedPicker: true` stamped on
  the project. POST /api/projects and PATCH /api/projects/:id reject
  any client-supplied `fromTrustedPicker` (privileged the same way as
  `baseDir`), and the PATCH preservation block re-stamps the marker
  on partial-metadata patches so it cannot be silently stripped.

- Daemon sidecar IPC handler: REGISTER_DESKTOP_AUTH calls
  setDesktopAuthSecret with the base64-decoded secret. The HTTP and
  IPC servers share a process so the registration takes effect
  immediately for the next inbound /api/import/folder call.

Tests:

- apps/daemon/tests/desktop-import-token-gate.test.ts (15 cases): web
  mode acceptance, no-token rejection, malformed-token rejection,
  wrong-secret rejection, wrong-baseDir rejection, expired rejection,
  oversized-window rejection, valid mint + trusted-picker stamp +
  replay rejection, plus 6 pure-helper cases for verifyDesktopImportToken.
  afterAll() clears the secret to keep the shared HTTP server clean
  for sibling test files.

- apps/daemon/tests/projects-routes.test.ts (+2 cases): POST and PATCH
  reject `fromTrustedPicker` in client-supplied metadata.

Existing folder-import-route.test.ts continues to pass because none of
those tests register a desktop secret; the gate stays dormant.

* fix(desktop,web): atomic pickAndImport replacing pickFolder; openPath trusted-picker check

Closes the renderer→arbitrary-baseDir bypass at the bridge boundary.
The renderer no longer receives a raw filesystem path from the main
process; the picker dialog and the import call live in a single
main-process transaction.

Desktop main:

- runDesktopMain generates a per-process 32-byte secret and registers
  it with the daemon over the daemon's sidecar IPC *before* the
  BrowserWindow is created. registerDesktopAuthWithDaemon retries a
  few times because tools-dev / tools-pack spawn daemon, web, and
  desktop as siblings, so the daemon may not be listening yet on
  desktop boot. A failed registration logs a warning and the runtime
  refuses pickAndImport calls (no secret → no token can be minted).

- runtime.ts replaces the `dialog:pick-folder` IPC with
  `dialog:pick-and-import`. The handler shows the picker, mints an
  HMAC token bound to the chosen path, POSTs /api/import/folder via
  the discovered web URL with the token + body, and returns the
  daemon's ImportFolderResponse to the renderer (or a structured
  failure envelope). Renderer never sees the path or the token.

- shell:open-path now consults a new pure helper
  `isOpenPathAllowedForProject` that refuses folder-imported projects
  whose metadata lacks `fromTrustedPicker: true`. This is the literal
  interpretation of mrcfps's round-3 follow-up: openPath is gated to
  projects whose resolvedDir came from the trusted-picker flow, not
  just transitively via the import gate. Native projects (no
  baseDir → daemon-owned <projectsRoot>/<id>) are always safe to open.

- fetchResolvedProjectDir now returns a `ResolvedProjectDirContext`
  with hasBaseDir + fromTrustedPicker so the openPath handler can
  enforce the marker check.

- New `signDesktopImportToken` pure helper mirrors the daemon-side
  signer with the same `~`-separated wire shape, exported for the
  packaged workspace's test file.

Preload bridge:

- `pickFolder` is deleted. The new `pickAndImport(init?)` returns the
  daemon's import response or a structured failure. `openPath` keeps
  its existing signature; its trust gate now lives in the main
  process.

Web renderer:

- electron.d.ts drops `pickFolder` and adds `pickAndImport` with the
  shared DesktopPickAndImportResult union pulled from contracts.

- NewProjectPanel: when running on Electron (pickAndImport bridge
  present), the "Open folder" button calls pickAndImport atomically
  and forwards the response through a new `onImportFolderResponse`
  prop. On web (no bridge), the existing manual baseDir input keeps
  working — browser builds have no shell.openPath surface so a
  renderer-named path cannot escalate.

- EntryView and App.tsx pass through the new callback. App's
  `handleImportFolderResponse` updates state from the response without
  a second fetch (the import already happened in the main process).

Tests (apps/packaged/tests/desktop-project-root-gate.test.ts):

- 3 cases for `isOpenPathAllowedForProject`: native allowed,
  trusted-picker allowed, legacy folder-import refused.

- 6 cases for `signDesktopImportToken`: shape (~-separated), determinism,
  signature flips when secret/baseDir/nonce/exp changes.

- Existing fetchResolvedProjectDir cases extended for the new
  `context` shape and additional cases that prove the metadata
  inspection (hasBaseDir, fromTrustedPicker) reads the daemon
  response correctly.

* fix(daemon): make desktop import-folder gate fail-closed (PR #974 round 4)

lefarcen P1 on round 3 of PR #974: the gate's `secret == null → accept`
branch (originally intended to keep web-only deployments unaffected)
let a renderer bypass the import boundary in two real desktop edges:

- Startup race: desktop's REGISTER_DESKTOP_AUTH IPC hasn't reached the
  daemon yet, but the renderer is already alive in the BrowserWindow
  and races to fetch /api/import/folder directly with arbitrary baseDir.
- Daemon restart mid-session: the new daemon process boots tokenless
  while a desktop is still running. Same shape: renderer fetches the
  route, daemon falls through to "web mode", accepts the untrusted
  baseDir. shell.openPath rejects (no fromTrustedPicker marker) but
  the daemon's other file APIs (read/write project files, list
  directories) operate on the attacker-chosen path.

Two coordinated mechanisms close that:

(1) Sticky in-process flag. `desktopAuthEverRegistered` flips to true
    on first non-null `setDesktopAuthSecret(...)` and never goes back.
    setDesktopAuthSecret(null) (used by tests) does NOT relax the gate
    so production code can never silently fall back to fail-open. Add
    `resetDesktopAuthForTests()` for vitest cleanup.

(2) Orchestrator-pinned mode via OD_REQUIRE_DESKTOP_AUTH=1 read at
    module load. tools-dev / tools-pack / apps/packaged set this when
    the daemon is spawned in a desktop-bundled flow (separate commits).
    With the env set, the gate is active from request 0 — a renderer
    racing /api/import/folder before registration completes gets a
    503 DESKTOP_AUTH_PENDING (transient, retry).

Standalone-daemon (web-only) deployments where neither mechanism fires
keep the gate dormant and the route's behavior unchanged.

Also addresses lefarcen P3 (whitespace HMAC mismatch): the desktop
signs the exact picker output, so the daemon must verify the same
string. The previous version trimmed `baseDir` before HMAC, which
would reject legitimate paths whose final component carried edge
whitespace. Use the raw request-body baseDir for verification; the
existing trim()+realpath() logic still normalizes for fs operations.

New error code: `DESKTOP_AUTH_PENDING` (HTTP 503, retryable).

Tests:

- `stays fail-closed (503 DESKTOP_AUTH_PENDING) after a registered
  secret is cleared` — exercises the sticky flag.
- `verifies the exact request-body baseDir, not a trimmed version` —
  pins the round-4 P3 fix.
- All existing desktop-import-token-gate cases continue to pass; the
  beforeEach/afterEach/afterAll resetters now use
  resetDesktopAuthForTests() to honor the sticky flag.

* fix(tools-dev,packaged): pin desktop import-auth on daemon spawn

PR #974 round-4 P1 follow-through. The daemon-side fail-closed gate
needs OD_REQUIRE_DESKTOP_AUTH=1 in the daemon's spawn env whenever
the daemon is paired with a desktop, so the gate is active from
request 0 and the daemon-restart-mid-session bypass cannot reopen.

tools-dev:
- spawnDaemonRuntime accepts a `requireDesktopAuth` option that
  appends OD_REQUIRE_DESKTOP_AUTH=1 to the spawn env.
- startDaemon takes the same flag and additionally checks whether a
  desktop runtime is already alive in this namespace; either branch
  pins the env (revival case where the daemon died mid-session and
  the user runs `tools-dev start daemon` to bring it back up).
- startApp threads the bundled-target list down so the daemon spawn
  knows when desktop is queued in the same orchestration even though
  the daemon starts first.
- The `start` / `restart` / `run` command actions pass the resolved
  target list into startApp.

apps/packaged:
- Packaged builds always pair a desktop with the daemon, so
  startPackagedSidecars unconditionally sets OD_REQUIRE_DESKTOP_AUTH=1
  in the daemon child env. Headless builds also flow through this
  same path, so the same gate applies.

Standalone-daemon flows unaffected: `tools-dev start daemon` (alone,
no desktop running, no desktop in the bundled target list) does not
set the env, and the daemon's gate stays dormant — current web-only
behavior is preserved.

* fix(desktop,web): align project-id regex with daemon; surface pickAndImport failures

mrcfps round-4 nits on PR #974.

apps/desktop/src/main/runtime.ts (mrcfps #1): the previous client-side
regex `^[a-zA-Z0-9_-]+$` rejected `.` even though the daemon's
canonical isSafeId / POST /api/projects accept `[A-Za-z0-9._-]{1,128}`.
Result: dotted ids like `my-project.v2` were valid backend-side but
got "project id contains disallowed characters" before
fetchResolvedProjectDir even hit the network, regressing Continue in
CLI / Finalize for those projects. Align the regex with the daemon's
shape, comment-tag the rationale.

apps/packaged/tests/desktop-project-root-gate.test.ts: add a
regression case for a dotted id and one for the 128-char length cap
(the new regex exposes both, the old regex obscured the dotted one).

apps/web/src/components/NewProjectPanel.tsx (mrcfps #2): the
`if (!result || result.ok !== true) return` branch swallowed every
non-OK pickAndImport shape (`desktop auth secret not registered`,
`web sidecar URL not available`, daemon HTTP errors with details)
the same way as the explicit `{ canceled: true }` cancel — leaving
the user with a silent no-op when the trusted-picker flow couldn't
even get off the ground. Reserve silent-return for the cancel case
only; surface every other reason via a Toast (existing component,
already used by ProjectView for related Continue-in-CLI flows).
The new `formatPickAndImportErrorDetails` helper flattens daemon
ApiError envelopes into a single readable secondary line so the
operator sees both the category ("Open folder failed: daemon
returned HTTP 503") and the upstream reason
("desktop auth required but secret not yet registered").

* docs(architecture): document desktop folder-import auth boundary

lefarcen P3 on PR #974 round 4: the `Folder import` section in
docs/architecture.md still documented only realpath / sandbox /
RUNTIME_DATA_DIR checks and omitted the new desktop HMAC trust
boundary, replay/TTL behavior, fail-closed semantics, daemon-restart
edge, and legacy-import migration note. Without that subsection it's
hard to review whether the 60s TTL, the `~`-separated token shape,
or the legacy folder-imports needing re-pick are intentional product
decisions or overlooked gaps.

Add a "Desktop folder-import auth (PR #974)" subsection covering:
- The trust handshake (32-byte secret over sidecar IPC at desktop boot).
- Token shape (`${nonce}~${exp}~${signature}`), HMAC payload, and
  why `.` cannot be the field separator (ISO 8601 expiries embed dots).
- TTL and replay behavior (60s, single-use, 2× TTL upper bound).
- Fail-closed mechanisms — sticky in-process flag and
  OD_REQUIRE_DESKTOP_AUTH env var pinning.
- Web-only deployments are unaffected (browser builds have no
  shell.openPath surface).
- The `metadata.fromTrustedPicker` marker and the openPath-side
  defense-in-depth check.
- Legacy folder-imports need re-pick to use the Continue-in-CLI button.
- Daemon-restart edge: 503 DESKTOP_AUTH_PENDING until desktop
  re-registers; restart desktop to recover.

* fix(packaged): skip desktop-auth gate in headless mode (PR #974 round 5 P2)

Round 5 (lefarcen P2): packaged headless mode (daemon+web only, no
Electron) was inheriting OD_REQUIRE_DESKTOP_AUTH=1 from the round-4
unconditional pin in startPackagedSidecars. Headless never runs desktop
main, so no client could ever register an HMAC secret and folder import
returned 503 DESKTOP_AUTH_PENDING permanently — even though headless has
no shell.openPath surface to exploit.

Plumb a required `requireDesktopAuth: boolean` option through
startPackagedSidecars: apps/packaged/src/index.ts (Electron entry)
passes true; apps/packaged/src/headless.ts passes false. Extract
buildPackagedDaemonSpawnEnv as a pure helper so vitest can pin both
branches without spawning a child process.

Tests added in apps/packaged/tests/sidecars.test.ts cover both branches
plus OD_LEGACY_DATA_DIR / daemonCliEntry env forwarding edges.

Refs: nexu-io/open-design#974

* fix(desktop,daemon): lazy auth retry + canonical HMAC binding (PR #974 round 5 P1+P3)

Round 5 (lefarcen P1, mrcfps): a daemon restart under
OD_REQUIRE_DESKTOP_AUTH=1 left desktop holding a stale secret while the
new daemon process required a fresh registration — folder import
returned 503 DESKTOP_AUTH_PENDING permanently until the user restarted
desktop. Same dead-end if the startup handshake missed its retry window.

Round 5 (lefarcen P3): the daemon verified the HMAC against raw
request-body baseDir, then trimmed before realpath(). A picker selection
of "/tmp/foo " could authorize an import of "/tmp/foo" — token bound to
a different path than the one imported.

Three coordinated fixes:

1. P1 lazy retry: extract pickAndImportFolder as a pure helper that
   takes injected fetch / mintToken / registerDesktopAuth deps. On 503
   DESKTOP_AUTH_PENDING from /api/import/folder, re-invoke the
   registration callback once, mint a fresh token (new nonce + new exp
   keeps replay protection), and POST again. Single retry, no infinite
   loop. Other failure shapes return immediately to the renderer.

2. P1 wiring: runDesktopMain now ALWAYS passes desktopAuthSecret to the
   runtime regardless of whether the initial handshake succeeded, plus
   a registerDesktopAuthWithDaemon callback the runtime invokes lazily.
   Soften the startup warning text to match the new recovery semantics.

3. P3 binding: trim picker output ONCE on the desktop side before both
   signing the HMAC and POSTing. Daemon-side verification stays against
   raw request-body baseDir (round-4 behavior); the daemon's defensive
   trim before realpath() is now a no-op for desktop traffic and only
   load-bearing for web-mode callers (path.isAbsolute("  /foo  ") is
   false). End-to-end: desktop-signed string == request body == HMAC-
   verified string == realpath() input.

Tests:

- apps/packaged/tests/desktop-pick-and-import.test.ts (NEW, 7 cases):
  lazy-retry happy path; lazy-retry exhausted (re-register WAS called);
  single-attempt happy path (no unnecessary IPC); optional-callback
  no-op; non-503 failures bypass retry; network errors; non-PENDING 503
  bypasses retry.

- apps/daemon/tests/desktop-import-token-gate.test.ts: replace round-4
  whitespace test with two round-5 binding tests — the trimmed string
  flows end-to-end (HMAC verifies, project metadata.baseDir equals
  realpath of trimmed input), and a request whose body baseDir diverges
  from the HMAC-bound string is rejected 403.

docs/architecture.md §"Desktop folder-import auth" — update the daemon-
restart-edge bullet to describe the lazy-retry recovery (round 4 said
"restart desktop to recover", which is now wrong) and add a headless-
packaged-mode bullet describing the round-5 P2 gate exclusion.

Refs: nexu-io/open-design#974

* feat(sidecar-proto,daemon): surface desktopAuthGateActive over STATUS IPC (PR #974 round 6 prep)

Round 6 (mrcfps): the split-start dev flow `tools-dev start daemon` ->
`tools-dev start desktop` was leaving the daemon ungated because
`OD_REQUIRE_DESKTOP_AUTH=1` is only injected when daemon and desktop
spawn in the same orchestrator invocation. To fix that, tools-dev needs
to introspect the running daemon's gate state before launching desktop
main — but the existing STATUS IPC didn't carry the flag.

This commit extends `DaemonStatusSnapshot` with a required
`desktopAuthGateActive: boolean` and wires the daemon sidecar's STATUS
handler (and the public `status()` method on the handle) to recompute
the value from `isDesktopAuthGateActive()` per request, since the flag
flips after `REGISTER_DESKTOP_AUTH` and stays sticky.

Extracted `withCurrentDesktopAuthGate(snapshot)` as a tiny pure helper
so the wiring is testable without booting a real IPC server. The new
test pins four scenarios:
- no secret registered (web-only mode) -> false
- after `setDesktopAuthSecret(buf)` -> true
- after `setDesktopAuthSecret(null)` (sticky) -> still true
- input snapshot's stale value is overridden by the live flag

The orchestrator-side consumer lands in the next commit
(`tools/dev/src/desktop-auth-gate.ts`).

Refs: nexu-io/open-design#974

* fix(tools-dev): auto-restart ungated daemon before desktop start (PR #974 round 6 mrcfps)

Round 6 (mrcfps): the split-start dev sequence
`tools-dev start daemon` -> `tools-dev start desktop` was leaving the
daemon running without `OD_REQUIRE_DESKTOP_AUTH=1`. The env var is
only injected when (A) daemon and desktop spawn in the same
orchestrator invocation (`startApp` line ~682) or (B) a desktop
runtime is already alive at daemon spawn time (`startDaemon` lines
~595-596). Neither fires for the split flow, so a renderer (or any
local HTTP client) could `POST /api/import/folder` directly with an
arbitrary `baseDir` before the desktop's first registration POST.
Round-5's lazy retry didn't help: it triggers on `503 DESKTOP_AUTH_PENDING`,
and the ungated daemon returns 200.

Close the gap by introspecting the running daemon's
`desktopAuthGateActive` (added to the STATUS IPC in the prior
commit) at the start of `startApp(DESKTOP, ...)`. When the daemon
reports the gate inactive, stop the daemon (and web, if running),
respawn the daemon with `requireDesktopAuth: true`, restart web,
then proceed with the desktop start. Restart order is critical and
pinned by tests: web stops FIRST (so the web->daemon proxy doesn't
serve a transient 502 against the down-then-up daemon), then daemon
stops, then daemon respawns gated, then web restarts.

The bundled-targets path (`pnpm tools-dev`) is unaffected because
trigger (A) already armed the gate at first daemon spawn — the
helper costs one ~800ms STATUS IPC roundtrip and returns no-op.

Helper lives in its own module (`tools/dev/src/desktop-auth-gate.ts`)
so the regression test can import it without triggering the
`cli.parse()` side effect at the bottom of `tools/dev/src/index.ts`.
Five `node:test` cases pin the call sequence — no daemon, gate
active, gate inactive + no web, gate inactive + web running, log
shape — so a future refactor can't silently regress the gate.

Two synthetic `DaemonStatusSnapshot` literals in `inspectAppStatus`
and `inspect` (used when the IPC is unreachable) get
`desktopAuthGateActive: false` to satisfy the now-required type
field — semantically correct since "no daemon answering" trivially
means "no gate active."

`docs/architecture.md` adds a new bullet under the Desktop folder-
import auth section describing this auto-restart behavior.

Refs: nexu-io/open-design#974

* fix(daemon): combine finalize request-abort + timeout signals (PR #974 round 7 lefarcen P1)

Round 6 wired the route handler to pass `finalizeAbort.signal` into
`finalizeDesignPackage`, but the helper only created its own
DEFAULT_TIMEOUT_MS controller when no caller signal was supplied. The
result: a client that stayed connected could hold the finalize lock and
upstream call indefinitely. Always create the timeout controller; when
the caller passes a signal, combine both via `AbortSignal.any` so
neither cancel path replaces the other.

Adds two regression tests in finalize-design.test.ts:
- timeout fires when caller signal never aborts
- pre-aborted caller signal still cancels

Adds an internal `timeoutMs` option to FinalizeOptions so tests can
exercise the abort path without a 120 s wait or fake-timer chains.
Production callers omit it; default remains DEFAULT_TIMEOUT_MS.

* fix(daemon): allow PATCH preserving existing fromTrustedPicker marker (PR #974 round 7 lefarcen P2)

The PATCH /api/projects/:id handler was rejecting any metadata that
contained `fromTrustedPicker`, including the unchanged `true` marker
that the linked-folder UI re-spreads when editing `linkedDirs`. Trusted
folder-imported projects could not update other metadata fields without
400-ing on their own marker.

Switch the rejection condition from `'in'` to a value comparison: only
reject when the incoming value differs from the persisted one
(`patch.metadata.fromTrustedPicker !== existingMeta?.fromTrustedPicker`).
That keeps acquisition (existing=undefined, patch true) and flip
(existing=true, patch false) attempts blocked while letting the UI
re-spread the existing marker.

POST /api/projects stays strict; that path has no existingMeta.

Adds two regression tests in desktop-import-token-gate.test.ts:
- allows PATCH preserving the existing fromTrustedPicker:true marker
- rejects PATCH that flips fromTrustedPicker on a trusted project

* fix(desktop,packaged): main-process api uses daemon URL not webUrl (PR #974 round 7 lefarcen P2)

Packaged builds load the renderer from `od://app/` and report that URL
through `discoverWebUrl`. But Node-side `globalThis.fetch` (undici) does
not route through Electron's registered `od://` protocol handler — that
handler runs in the renderer's protocol scope, not in main-process Node.
So `pickAndImportFolder` and `fetchResolvedProjectDir` calls from main
silently failed in packaged builds against the protocol scheme.

Add `discoverDaemonUrl` to `DesktopRuntimeOptions` and `DesktopMainOptions`.
The packaged shell already has the sidecar's real `http://127.0.0.1:<port>`
URL (`sidecars.daemon.url` from STATUS IPC) — thread it through to the
runtime. Main-process API calls now prefer the daemon URL and fall back
to the renderer URL for tools-dev (where it is itself http://127.0.0.1).

`PickAndImportFolderDeps.webUrl` renamed to `apiBaseUrl` so the boundary
is explicit at the type level; `fetchResolvedProjectDir`'s first
parameter renamed similarly. tools-dev callers see no behavior change —
their web URL is already an http://127.0.0.1 URL Node fetch can hit.

Test (`apps/packaged/tests/desktop-pick-and-import.test.ts`):
- existing 7 cases updated to the new prop name (no behavior change)
- new case pins URL composition: builds `${apiBaseUrl}/api/import/folder`
  and never produces a custom-protocol URL.

Note for review: this test pins URL composition; full Electron protocol
handler integration (renderer fetch through `od://`) is not exercised in
unit tests here.

* fix(tools-dev): preserve daemon/web ports across desktop-auth gate restart (PR #974 round 7 lefarcen P2)

Round 6 added the split-start auto-restart in ensureDaemonGateForDesktop
to close the dev-flow gap where `start daemon` then `start desktop`
left the daemon ungated. The restart was passing the current
`start desktop` CLI options to startDaemonGated/startWeb, which meant a
stack started with `--daemon-port 17456 --web-port 17573` could be
silently moved to random ports during the hardening restart, breaking
browsers and scripts pinned to those ports.

Extract the running ports from the STATUS snapshots (daemon.url and
web.url) and forward them as explicit `{ port }` callback args. The
closure in `tools/dev/src/index.ts` overrides the corresponding option
when a port was extracted; null falls back to the original CLI flags.

Adds three regression tests in tools/dev/tests/desktop-auth-gate.test.ts:
- preserves the running daemon port across the hardening restart
- preserves the running web port across the hardening restart
- falls back to caller options (port:null) when the URL has no port

* fix(web): refresh useDesignMdState on file/chat events (PR #974 round 7 mrcfps)

useDesignMdState() previously only recomputed on mount and on explicit
refresh() (called once after finalize). Once the user kept working —
editing files or sending more chat turns — the stale/fresh badge could
drift out of sync because file mtimes and conversation updatedAt moved
past the recorded generatedAt without the hook re-checking.

Hook accepts an optional `refreshKey: number` arg; ProjectView keeps a
counter and bumps it on three events:
- file-changed SSE (covers tool-emitted file mutations)
- live_artifact* SSE (covers chat turns that emit artifacts)
- streaming `true → false` edge (covers pure-text chat turns)

The hook treats refreshKey as a compute() dep; React's Object.is
comparison short-circuits the no-op renders, so each bump is a single
recompute pass.

Adds a regression test in useDesignMdState.test.tsx:
- flips stale state after a refreshKey bump without remounting

* fix(web): degraded-state useDesignMdState on malformed provenance (PR #974 round 7 mrcfps)

useDesignMdState used to report `{ isStale: false, staleReason: null }`
when the parser could not extract a comparison timestamp from the
DESIGN.md `## Provenance` section. The pinned test made that the
documented behavior. As mrcfps pointed out, that fails open exactly
when the freshness signal is most untrustworthy: any provenance-
formatting drift silently disables the staleness warning.

Extend `DesignMdStaleReason` with a third variant `'unknown-provenance'`.
On `generatedMs === null`, return `{ isStale: true, staleReason: 'unknown-provenance' }`.
ContinueInCliButton renders a distinct chip text "Spec freshness
unknown — regenerate to refresh signal" for that variant; the button
stays enabled because not-comparable is not the same as broken state.

Tests:
- modify the existing pinned test to assert the new degraded state
- add an end-to-end useDesignMdState test feeding a malformed Provenance
  section through compute() so a regression that re-pins fresh-on-null
  at the hook level (not just computeStale) fails fast
- add ContinueInCliButton render + click tests for the new chip

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-10 11:44:32 +08:00
PerishFire
cc343f8828
ci: optimize beta release packaging cache (#1095)
* ci: optimize beta release packaging cache

* fix: version windows builder cache

* fix: forward linux app version in container
2026-05-10 10:11:05 +08:00
Marc Chan
b03a504da6
release: Open Design 0.6.0 (#1080) 2026-05-09 19:58:11 +08:00
PerishFire
dcfab797c2
[codex] Add stable nightly promotion gate (#962)
* Upload beta e2e spec reports to R2

* Expose beta report URLs in summary

* Complete Indonesian deploy locale keys

* chore: factor release workflow scripts

* chore: bump packaged beta base version

* test: wait for mac packaged runtime health

* fix: capture mac packaged startup logs

* chore: improve mac release build observability

* fix: ad-hoc sign unsigned mac builds

* chore: diagnose mac packaged startup

* fix: relax unsigned mac launch signing

* chore: improve mac launch diagnostics

* chore: simplify beta mac release artifacts

* fix: align packaged mac smoke launch config

* fix: externalize mac daemon wasm dependency

* chore: require signed stable mac releases

* fix: use stable app version for nightly package builds

* chore: clean release artifacts after publish

* chore: publish beta reports as zip

* ci: disable beta mac tools-pack cache

* fix: skip mac framework binary symlinks when signing

* fix: sign mac framework version bundles

* ci: disable beta mac pnpm cache

* chore: align stable release reports

* ci: require matching nightly before stable release

* ci: avoid mac pnpm cache for packaged smoke
2026-05-08 21:48:54 +08:00
ferasbusiness666
1e8926271b
Harden security scan findings and upgrade dependencies (#806)
* feat: add accent color control and launcher for Open Design

* fix: remove launcher binary from PR

* test: cover accent appearance edge cases

* Harden security scan findings and upgrade deps

* Address proxy security review

* Pin jsdom for web test stability

---------

Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 19:46:34 +08:00
Marc Chan
b06f26a5fd
test: strengthen e2e PR coverage (#796)
* test: strengthen e2e PR coverage

* fix: address e2e PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* ci: cache Windows packaged smoke builds

* test: fake additional agent runtimes

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Route tools-pack mac starts through a launch-time packaged config override so portable packaged smoke runs keep using the namespace runtime root that inspect and logs expect.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Fall back to the packaged app's embedded config when the build output config is missing so installed mac starts still work.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: align packaged mac PR smoke with tools-pack runtime mode

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Keep blake3-wasm out of the packaged mac daemon prebundle so the standalone runtime loads the Cloudflare asset hasher from node_modules instead of crashing in ESM.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Skip the portable mac launch override when the bundled packaged config is missing so installed fallback app targets can still boot with packaged defaults.

Add a regression test covering the missing-config start path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(pack): remove duplicate mac prebundle dependency key
2026-05-08 16:48:10 +08:00
Marc Chan
e14b8092ea
feat: add Orbit activity summaries (#681)
* feat: add Orbit activity summaries

* fix(orbit): make runs navigable while agent continues

* fix(web): widen minimum chat panel

* feat: support Orbit template selection

* fix(daemon): avoid bogus skill side-file preflight

* fix(web): collapse orbit artifact project cards

* fix(web): preserve orbit project card titles

* fix: improve Orbit run daily briefing

* fix: handle Orbit digest data failures

* fix: load Orbit templates and connector tools reliably

* fix: keep Orbit summary counts consistent

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: apply Orbit template skill context

* fix: cache and curate connector tools for Orbit

* fix: align Orbit defaults and connector discovery

* fix: simplify Orbit template settings

* fix: move connectors into settings

* fix: compact connector settings catalog

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: prevent connector action button from stretching into pill

The icon-only connect/disconnect buttons in the embedded connectors
catalog inherited min-width: 92px / 106px from the non-embedded pill
rules, overriding the 24px square sizing and causing the buttons to
overlap the card head text. Reset min-width to 0 in the embedded
icon-only rule so the compact square layout holds.

* fix(web): align live artifact file rows

* fix: clean up Orbit connector settings lifecycle

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address Orbit review regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* feat(web): localize Orbit and connector settings

* feat(web): gate Orbit runs without connectors

* feat(web): refine connector settings UX

* feat(web): safeguard Composio key clearing

* fix(web): refresh Composio tool badges

* feat(web): show connector logos

* feat(daemon): localize Orbit prompt window

* fix(daemon): clarify blocked connector callback closes

* test(daemon): harden flaky async probes

* fix(web): align Indonesian connector locale keys

* test(web): align connector browser props

* fix(web): preserve explicit credential clears

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): time out Composio logo proxy fetches

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Indonesian connector settings copy

Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve discovered connector tools

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve onboarding autosave completion

Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): defer Composio catalog cache hydration

Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): treat discovery completion independently

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve latest settings draft on close

Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): avoid syncing draft Composio key on Orbit run

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Orbit settings copy

Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): prefer fresh connector catalog state

Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): declare Indonesian locale fallback keys explicitly

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): inline Indonesian fallback strings for CI

Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI.

Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): restrict proxied connector logos to image MIME types

Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* test(e2e): align settings dialog regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): decouple Orbit runs from media sync failures

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): keep SPA catch-all export-compatible

Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve Orbit config and workspace routes

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): block SVG in connector logo proxy

Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): fall back to static catalog for empty cache

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): disable Orbit run before connector gate resolves

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(desktop): export shipped desktop types

Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore persisted question form selections

Render historical submitted answers directly so reloaded question forms keep their locked selections visible.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): retry forced media sync autosave

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep Composio logo timeout through body read

Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): refresh Orbit gate after connector auth

Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep connector detail tool lists intact

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): ignore malformed Orbit summaries

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(e2e): stabilize design-system multi-select flow

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): cap Composio logo cache growth

Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): bound proxied Composio logo payloads

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): align autosave settings tests

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): remove stray CSS conflict marker

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fixer: address PR #681 follow-up items

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore restart routes and connector flows

* fix(web): keep SPA export route static

* fix(web): stabilize chat scroll tests

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 14:27:46 +08:00
shangxinyu1
aec9428b08
Fix desktop preview and packaged app interactions (#879)
* Fix packaged deck navigation interactions

* Fix connector auth in packaged app and localized content coverage

* Fix Electron connector browser handoff contract
2026-05-08 14:26:10 +08:00
Tom Huang
56bf6ee1b6
feat: agent-callable research command and /search (#615)
* feat: pre-generation research (Tavily) for grounded generation

Adds an optional pre-generation research step so the agent can produce
slides / prototypes / decks grounded in real sources instead of guessing.

User flow:
  1. Settings -> Tavily Search -> paste API key (or set TAVILY_API_KEY).
  2. Click the new Research button in the chat composer.
  3. On send, the daemon runs a Tavily search, prepends the findings
     as a <research_context> block ahead of the system prompt, and
     spawns the agent. Research progress shows up as status pills in
     the chat stream; the agent cites sources inline as [1]/[2]/...

Phase 1 surface:
  - Single provider (Tavily), single depth ('shallow'), no LLM
    synthesis pass (Tavily's `answer` is the summary).
  - Composer toggle only; no popover / depth picker yet.
  - Reuses the existing `status` SSE agent payload + StatusPill UI
    so no new event variants or renderer code are needed.

Layers touched:
  - contracts: ResearchOptions / Source / Findings DTOs;
    ChatRequest.research; export from index.
  - daemon: apps/daemon/src/research/{index,tavily}.ts orchestrator
    + provider; tavily added to MEDIA_PROVIDERS and ENV_KEYS; hook
    in startChatRun before prompt assembly.
  - web: ChatComposer toggle + ChatSendMeta; threaded through
    ChatPane / ProjectView / streamViaDaemon into ChatRequest.

Side fix (required to land the feature, but useful on its own):
  contracts internal relative imports lacked the `.js` suffix that
  NodeNext module resolution requires. This was already breaking
  `pnpm --filter @open-design/daemon typecheck` on main; without the
  fix, none of the new research types were visible to the daemon.
  All internal contracts imports now carry `.js`.

Spec: specs/current/research-feature.md (phases 2-4 outlined for
follow-up: composer popover, multi-provider, deep recursion, example
skills with research_recommends).

Verified:
  - pnpm --filter @open-design/contracts typecheck/test
  - pnpm --filter @open-design/daemon typecheck (the chokidar
    project-watchers test is a pre-existing flake, unrelated)
  - pnpm --filter @open-design/web typecheck
  - node scripts/verify-media-models.mjs

* fix(daemon): clamp Tavily max_results to 20

Tavily's /search endpoint requires `max_results` in [0, 20]; sending a
larger value (e.g. when `research.depth: "deep"` resolves to 30) returns
400 and `runResearch` silently falls back to no-research. Clamp at the
provider boundary so Phase 2 depth tiers above 20 still produce results
instead of failing the request.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* Remove stale research merge leftovers

* Add agent-callable research search

* Fix Indonesian locale typecheck

* Fix research command invocation edge cases

* Harden slash search prompt expansion

* Honor research source caps in command contract

* Require search reports in design files

* Add research data provider settings

* Wire web research provider fallback order

* Update research provider fallback wording

* Revert "Update research provider fallback wording"

This reverts commit 86fb6001e3.

* Revert "Wire web research provider fallback order"

This reverts commit 4c9e16036b.

* Revert "Add research data provider settings"

This reverts commit 23630d1746.

* Add Dexter and Last30Days research skills

* Add DCF and Last30Days OD skills

* Add Last30Days and Dexter skills

* Resolve research review threads

---------

Co-authored-by: a1chzt <chizblank@gmail.com>
2026-05-08 10:33:44 +08:00
lefarcen
2bb029cb58
release: Open Design 0.5.0 (#820)
0.5.0 已从 c21cbc6 发布(https://github.com/nexu-io/open-design/releases/tag/open-design-v0.5.0);本次 squash 把版本 bump 与 CHANGELOG [0.5.0] 条目带到 main 历史,便于后续 0.5.1 release 在 main 上走标准 dispatch 流程。
2026-05-08 00:41:01 +08:00
Nagendhra Madishetti
294fe94c67
fix(pack/win): close detection gaps that let Open Design.exe stay locked at install time (#821) (#823)
The custom NSIS pre-install flow detects and closes running OD processes
before extraction, but two gaps let `$INSTDIR\Open Design.exe` stay
locked when the installer reaches `MUI_PAGE_INSTFILES`. The user then
sees NSIS's native "file in use" Retry/Cancel dialog (not the custom
`RunningInstancesCloseFailed` text), which is what kutzki reported.

`DetectRunningInstances` and `CloseRunningInstances` previously matched
processes only by `Win32_Process.ExecutablePath` under the install
root. WMI returns null `ExecutablePath` for processes the caller
cannot fully introspect: insufficient access tokens, processes
mid-spawn, protected-process states. A child spawned in the millisecond
window between the previous OD running and the installer's detection
step can hit this and slip past the filter. Both functions now fall
back to a CommandLine prefix match against the install root for null-
`ExecutablePath` rows, which is OD-specific enough to avoid false
positives without relying on a global `Name` match.

`CloseRunningInstances` previously called `Stop-Process -Force` and
returned without waiting for the OS to actually finalize the process
exit. On Windows the file handle GC for an exiting process is async,
so a `MUI_PAGE_INSTFILES` overwrite right after the kill can race the
handle release and trigger NSIS's native file-in-use prompt even
though the kill succeeded. The function now `WaitForExit(5000)` per
PID after the force-stop loop, before returning, so the lock has time
to clear before NSIS attempts the overwrite.

Both changes were endorsed by @lefarcen in the issue thread after they
ran their own code review and confirmed the matching diagnosis. The
third part of the proposed fix (cross-platform `before-quit` cleanup
in the Electron app) is in scope for #422 and not touched here.

Local validation: `pnpm guard` clean. `pnpm --filter @open-design/tools-pack
typecheck` fails on a pre-existing issue (missing `@electron/rebuild`
devDep in tools-pack/src/win/app.ts on current main, reproducible by
checking out main directly without my edit), unrelated to this change.
The PowerShell embedded in the NSIS template is not exercised by the
workspace test suite, so the change has no unit-test surface.

Honest caveat: I do not have a Windows packaged-build environment to
run `pnpm tools-pack win build --to nsis` and reproduce the
locked-file dialog end-to-end. The PowerShell edits are textual and
match the patterns already in the file, but a verifying install pass
on a real Windows host with a previous OD already installed and
running is recommended before merge.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-07 21:42:50 +08:00
PerishFire
cb92c93ae0
Migrate beta release publishing to R2 (#805)
* Prebundle standalone web packaged runtime

* Harden mac standalone prebundle policy

* Prebundle mac daemon packaged runtime

* Prune mac Electron locales

* Maximize mac release artifact compression

* Publish beta mac artifacts to R2

* Use remote R2 uploads for beta releases

* Fail fast on beta R2 access issues

* Use S3-compatible uploads for beta R2 releases

* Decouple beta versioning from GitHub releases

* Remove legacy beta metadata source

* Address release beta review notes
2026-05-07 19:13:52 +08:00
PerishFire
6efac8887e
Improve Windows beta packaging and installer flow (#768)
* Optimize Windows packaged web output

* Fix packaged contracts runtime build

* Optimize Windows packaged size pruning

* Prune Windows root Next payload

* Remove Windows bundled Node runtime

* Prune Windows standalone duplicate Next

* Add tools-pack cache foundation

* Cache Windows packaged build layers

* Cache Windows workspace builds

* Cache Electron-ready Windows app

* Split Windows tools-pack module

* Cache Windows dir build outputs

* Split Windows pack build modules

* Document Windows NSIS smoke namespace limits

* Move Windows NSIS smoke note to agents guide

* Optimize Windows beta packaging

* Bump packaged beta base version

* Improve Windows installer namespace UX

* Improve Windows tools-pack cache keys

* Stabilize Windows beta cache version keys

* Cache Windows workspace build outputs

* Optimize windows release beta cache layers

* Cache windows release dependencies

* Trim windows release cache before save

* Refresh windows tools-pack cache key

* Improve windows installer preflight prompts

* Fallback NSIS installer strings to English

* Fix Windows installer cleanup and preflight

* Improve Windows NSIS state logging

* Fix system NSIS Persian language alias

* Use long-path removal for Windows uninstall

* Fix mac tools-pack tests on Windows

* Address Windows packaging review feedback

* Fix Windows installer cache namespace isolation

* Include web output mode in Windows tarball cache key

* Use unique Windows release cache save keys
2026-05-07 16:44:15 +08:00
shangxinyu1
9b501f12a5
Support overriding the Codex executable path (#755)
* Support overriding the Codex executable path

* Replace save-as-template prompts with an in-app dialog

* Seed local packaged app config from workspace

* Fix packaged config and connection test overrides

* Keep tools-pack mac config seeding self-contained

* Require absolute CODEX_BIN overrides
2026-05-07 15:00:52 +08:00
Jheison Martinez Bolivar
4368b8f163
feat(linux): add headless mode for install/start/stop operations (#686)
* feat(linux): add headless mode for install/start/stop operations

* docs(linux): document headless mode commands and usage

* refactor(linux-headless): write web-root.json instead of polling IPC for URL

* fix(linux-headless): fail start when web identity never appears instead of returning success

* docs(linux-headless): add use-case context and clarify launcher path dependency

* fix(linux-headless): ensure launcher, identity and shutdown align with tools-pack

- Bake OD_DATA_DIR into launcher so manual runs use the same paths as tools-pack
- Validate web-root.json fields before accepting to reject stale identity
- Remove web-root.json on successful stop
- Add IPC server for graceful STATUS/SHUTDOWN handling

* fix(linux-headless): create IPC server before writing web-root.json
2026-05-07 01:52:03 +08:00
Feroomon2010
576dfed9e1
feat: add accent color control and launcher for Open Design (#683)
* feat: add accent color control and launcher for Open Design

* fix: remove launcher binary from PR

* test: cover accent appearance edge cases

---------

Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
2026-05-06 23:14:21 +08:00
iulian
80416b185a
Diagnose missing Next package during tools-dev web startup (#675)
* fix(tools-dev): diagnose missing Next package

* fix(web): remove duplicate Ukrainian prompt labels
2026-05-06 20:45:41 +08:00
iulian
f880fd8c1d
docs(tools-pack): fix Linux namespace env var (#670)
* docs(tools-pack): fix linux namespace env var

* fix(web): remove duplicate Ukrainian prompt labels
2026-05-06 20:11:40 +08:00
lefarcen
ae4a08773a
chore(release): prepare 0.4.1 (#659)
- bump remaining monorepo package.json files to 0.4.1 after apps/packaged was already bumped in #637
- add CHANGELOG.md [0.4.1] - 2026-05-06 entry covering the startup hotfix and 19 merged PRs since 0.4.0:
  - Added: manual edit mode (#620), Cmd/Ctrl+P quick file switcher (#556), resizable chat panel (#563), PI status/cancel updates (#618), accessibility and RTL/Bidi craft modules (#587, #595), i18n structure checks (#608)
  - Changed: first-PR README links now surface help-wanted issues (#605)
  - Fixed: packaged contracts runtime exports (#577), packaged runtime beta gating (#637), ACP/MCP/agent fixes (#604, #612, #627), conversation error recovery (#623), native mac quit (#637)
  - Documentation/Internal: OD_DATA_DIR migration docs (#570), Simplified Chinese QUICKSTART (#578), zh-TW/ko README syncs (#586, #619), generated metrics (#592)

Release workflow validation runs after merge via release-stable.
2026-05-06 18:05:56 +08:00
czL
3b6df849ce
fix linux test path (#657) 2026-05-06 18:00:48 +08:00
PerishFire
f1cdb2844a
test(e2e): gate beta packaged runtime (#637)
* test(e2e): gate beta mac packaged runtime

* test(e2e): separate ui automation layout

* test(e2e): move localized content coverage

* chore(release): prepare packaged 0.4.1 beta validation

* test(e2e): keep ui lane playwright-only

* fix(web): keep chat recoverable after conversation load failure

* fix(desktop): honor native mac quit
2026-05-06 17:44:29 +08:00
iulian
14a73d948b
Fix packaged contracts runtime exports (#577)
* fix packaged contracts runtime exports

* fix(packaging): prepare contracts runtime exports on install
2026-05-06 09:11:35 +08:00
lefarcen
74f1a18b71
fix(tools-pack): replace corepack with npx in linux container build (#558) 2026-05-06 00:05:22 +08:00
lefarcen
963bbf2500
release: Open Design 0.4.0 (#454) 2026-05-05 23:39:40 +08:00
ChildhoodAndy
009d7a5478
refactor(daemon): eliminate duplicate dist tree from two-tsconfig build (#553)
Move sidecar source under src/ so a single tsconfig produces all daemon
output. Removes the parallel dist/src/ tree that was emitted by
tsconfig.sidecar.json (it included src/**/*.ts to type-check the
`../src/server.js` cross-tree import).

Build now emits:
- dist/<flat>            (cli.js, server.js, app-version.js, ...)
- dist/sidecar/{index,server}.js

`dist/sidecar/server.js` reaches the main daemon via `../server.js`
instead of `../src/server.js`, so there is no second copy of the source
tree in the published tarball.

Background — issue #534 (already fixed by #537):
The packaged Settings → About panel showed 0.0.0 because the sidecar
chain loaded the duplicated `dist/src/app-version.js`, where the fixed
`new URL('../package.json', import.meta.url)` resolved to a non-existent
`dist/package.json`. #537 patched the symptom by walking parents until a
real `package.json` is found and by writing `appVersion` into the Linux
packaged config. Both stay in place — they're sound defenses — but the
underlying duplicate-emit was never addressed; any future relative
resource lookup (templates, schemas, prompts) anchored on
`import.meta.url` would have hit the same trap.

This change removes the trap.
2026-05-05 23:31:14 +08:00
Mason
cc6da191e8
fix(version): resolve daemon package.json from any compiled layout (#537)
Settings -> About used to display 0.0.0 in packaged builds because
`readCurrentAppVersionInfo` resolved `'../package.json'` relative to
`import.meta.url`, which only points at the daemon package root from the
flat CLI build (`dist/app-version.js`). The sidecar build emits
`dist/src/app-version.js`, where the same relative path lands on the
non-existent `dist/package.json`, so `readPackageMetadata` returned null
and the version fell back to APP_VERSION_FALLBACK.

Walk up from `import.meta.url` to find the nearest real `package.json`
instead, so the daemon reports its actual version regardless of whether
it runs from TypeScript source (tools-dev), the flat CLI dist, or the
nested sidecar dist used by the packaged desktop app. The OD_APP_VERSION
env still wins inside `resolveAppVersionInfo`, so callers that already
inject it (mac/win packagers) keep working.

Also write `appVersion` into the Linux packaged config so Linux follows
the same env-injection path as mac/win and stays consistent with the new
fallback resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:02:05 +08:00
PerishFire
bbdd4e84b5
chore: enforce test directory conventions (#496)
* chore: enforce test directory conventions

Move package, app, and tool tests out of src and add guard enforcement so source directories stay source-only.

* ci: use guard and package-scoped tests

Run the new repository guard in CI and keep test execution aligned with package-scoped commands after removing root aliases.

* ci: align stable release guard check

Use the new repository guard in stable release verification after replacing the residual-JS-only script.

* chore: tighten test layout enforcement

Enforce sibling tests directories, typecheck moved test suites with dedicated configs, and refresh remaining guidance that pointed at src-based tests.

* chore: clarify no-emit test tsconfigs

Explicitly disable declaration-only emit in test tsconfigs so review tooling sees they are no-emit typecheck configs.
2026-05-05 15:34:22 +08:00
PerishFire
3935aeb421
Optimize packaged mac artifact size (#424)
* optimize mac package payload reporting

* optimize(pack): package standalone web runtime

* optimize(pack): default to standalone web runtime

* chore(release): bump beta base version

* fix(pack): compress mac artifacts and report packaged version

* fix(pack): preserve Next server fallback

* fix(pack): clarify standalone startup failures

* fix(release): gate beta platform builds

* fix(web): bind standalone backend to parent

* fix(pack): harden standalone and beta publishing
2026-05-05 10:37:19 +08:00
soulme
483e00d24d
fix(pack): bundle prompt templates in desktop resources (#417) 2026-05-04 12:21:39 +08:00
lefarcen
a719f02aa2
fix(web): normalize daemon proxy origins
Fix web sidecar proxy requests so same-origin browser requests reach the daemon without tripping origin validation, while unrelated origins remain rejected. Fixes #388.
2026-05-04 03:39:19 +08:00
iulian
02638af353
Add linux x64 AppImage to tools-pack and release workflows (#369)
* feat(tools-pack): extend config types for linux platform

* feat(tools-pack): add linux resource files (icon, .desktop template)

* feat(tools-pack): export linuxResources paths

* feat(tools-pack): scaffold linux.ts module

* chore(tools-pack): add vitest devdep for linux lane unit tests

* feat(tools-pack): add buildDockerArgs helper for containerized linux builds

* chore: update pnpm lockfile after adding vitest dep

* feat(tools-pack): add renderDesktopTemplate helper

* fix(tools-pack): use @@ICON_PATH@@ token in linux .desktop template

Reviewer flagged the third .replace() in renderDesktopTemplate as dead code
because the template hardcoded Icon=open-design-@@NAMESPACE@@ instead of
using @@ICON_PATH@@. Switch the template to @@ICON_PATH@@ so install logic
controls the icon stem name independent of namespace, and move the
sanitizeNamespace assertion out of the renderDesktopTemplate describe block
into its own describe.

* feat(tools-pack): add matchesAppImageProcess helper

* test(tools-pack): cover matchesAppImageProcess missing APPIMAGE env case

Closes a coverage gap flagged by code review: a process whose executable
matches /tmp/.mount_*/AppRun but has no APPIMAGE env should be rejected.
The implementation already returned false for this case (undefined ===
installPath is false); this test pins the behavior explicitly.

* feat(tools-pack): implement packLinux native build path

* fix(tools-pack): packLinux extra resources, output pre-clear, publish never

Code review flagged three plan-level omissions in packLinux that mac.ts
handles correctly:

1. writeAssembledApp now writes packagedConfigPath (open-design-config.json)
   with namespace, nodeCommandRelative, and namespaceBaseRoot. Without it
   apps/packaged falls back to defaults at runtime and cannot find the
   namespace runtime tree.

2. writeLinuxBuilderConfig now bundles the resource tree and packaged
   config into the AppImage via extraResources. Without it the running
   app cannot find skills/, design-systems/, craft/, frames/, or the
   bundled bin/node.

3. runElectronBuilderLinux now pre-clears appBuilderOutputRoot and passes
   --publish never to electron-builder, preventing stale-artifact bleed
   between runs and accidental publish attempts in CI when env tokens
   are present.

Also aligns appId with mac/win (io.open-design.desktop) and drops a
no-op productNameSafe template-literal.

* feat(tools-pack): implement containerized linux build via Docker

* feat(tools-pack): register linux CLI commands

* fix(tools-pack): align linux electron-builder config with mac.ts

Smoke testing the AppImage revealed the daemon sidecar was missing from
the bundled app.asar:

  Cannot find module '@open-design/daemon/dist/sidecar/index.js'

Root cause: writeLinuxBuilderConfig was missing the 'files' field, so
electron-builder used defaults that excluded transitive workspace deps
from the asar. Plus several other mac.ts patterns that I dropped from
the plan: artifactName, executableName, extraMetadata.main/name/
productName/version, npmRebuild=false, nodeGypRebuild=false,
buildDependenciesFromSource=false, compression=maximum, top-level icon.

Switch asar:true → asar:false to match mac.ts (easier to debug missing
files; perf difference negligible for dev installs).

* feat(tools-pack): implement linux install

* feat(tools-pack): implement linux start with extract-and-run

The packaged sidecar's 35-second wait timeout is exceeded when the
AppImage runs from a FUSE-mounted SquashFS (Node module loads + daemon
init are slow through FUSE). Pass --appimage-extract-and-run as the
first arg so AppImage extracts to /tmp first; subsequent file reads
go through a real filesystem and daemon boot completes in time.

Wait for apps/packaged to write desktop-root.json (60s ceiling, generous
to cover AppImage extraction overhead), then fetch desktop status via
sidecar IPC, return the merged LinuxStartResult.

* fix(tools-pack): align linux start helper with mac.ts (log echo + write semantics)

Code review flagged two unjustified divergences from mac.ts in
startPackedLinuxApp:

1. Missing OD_DESKTOP_LOG_ECHO=0 in spawn extraEnv. Without it the
   packaged logger echoes to the spawned process's stdout, which goes
   nowhere (logFd: null). Added the suppression to match mac.ts.

2. The desktop log truncate writeFile() was wrapped in .catch(() =>
   undefined), silently swallowing fs errors that would later surface as
   confusing missing-log symptoms. Removed the .catch so errors
   propagate per mac.ts.

Also added an inline comment explaining the 60s waitForMarker timeout
(vs mac's tighter ceiling) so the rationale is preserved at the
call site.

* feat(tools-pack): implement linux stop with marker validation

* fix(tools-pack): align linux stop with mac.ts (graceful shutdown + reason strings)

Code review flagged divergences from mac.ts in stopPackedLinuxApp:

1. No graceful IPC SHUTDOWN attempt before SIGTERM/SIGKILL. Mac's
   pattern lets Electron renderers + sidecars flush state (SQLite WAL,
   logs) first. `gracefulRequested: true` was hardcoded, lying to
   callers about what actually happened. Now attempts SHUTDOWN with a
   1500ms timeout and reports the actual outcome.

2. The dead-PID-but-marker-exists branch returned reason 'ok' (the
   neutral placeholder from readDesktopRootIdentityMarker), which says
   nothing useful. Override to 'marker-pid-not-running' to match mac.ts.

3. After a clean stop, remove the desktop-root.json marker so a
   subsequent start has a fresh slate (mac.ts does this too).

* fix(tools-pack): clear stale desktop-root.json before linux start

Smoke-testing the install/start/stop loop revealed waitForMarker
returns instantly when a stale marker from a previous run still exists
on disk (e.g., the previous AppImage was killed without going through
'tools-pack linux stop'). The start function then reports success
without actually waiting for the new spawn to write its own marker.

Defensively remove the marker file before spawning. mac.ts removes it
in stop, so a clean stop->start sequence has nothing to remove here.
This only matters for crash-recovery.

* fix(tools-pack): linux stop validates extract-and-run AppImages

Smoke testing exposed a gap from Task 7: matchesAppImageProcess only
recognized FUSE-mode (/tmp/.mount_*/AppRun) but Task 13 launches with
--appimage-extract-and-run, which puts the live executable at
/tmp/appimage_extracted_<hex>/<binary>. Stop's cmdOk validation
returned false, marker validation failed, the running app was
classified as 'unmanaged' and stop refused to kill it.

Fix:
1. matchesAppImageProcess accepts both runner patterns. Extract-and-run
   regex matches /^\/tmp\/appimage_extracted_[^/]+\/[^/]+$/.

2. stopPackedLinuxApp now passes paths.installAppImagePath (or the
   built fallback) as the canonical install path, not marker.appPath
   (which apps/packaged unhelpfully writes as '/' on Linux).

3. linux.test.ts gains 2 new tests covering the extract-and-run mode
   (both positive and the wrong-APPIMAGE-env negative case).

* fix(tools-pack): resolve linux paths in stop (typecheck regression from previous commit)

* feat(tools-pack): implement linux logs

* feat(tools-pack): implement linux uninstall

* feat(tools-pack): implement linux cleanup

* docs(tools-pack): document linux lane in READMEs and AGENTS files

* ci(release): add linux x64 AppImage to release-beta and release-stable

Mirrors the existing build_mac/build_win pattern with a build_linux job
in both release workflows. Builds via `tools-pack linux build
--containerized --to appimage` so the AppImage is linked against the
electronuserland/builder glibc 2.27 baseline (portable across distros)
rather than the ubuntu-latest glibc 2.39.

The linux asset is uploaded to the immutable version release tag
alongside mac/win. The beta channel-feed release (latest-mac.yml,
latest.yml) is intentionally not extended with latest-linux.yml because
tools/pack/src/linux.ts has no electron-builder publish block wired,
so the auto-update feed would point users at a feed that never updates.
AppImage auto-update is a separate follow-on.

Linux is unsigned (no signing path in tools-pack yet), so the beta
asset uses the .unsigned suffix matching the windows convention; the
stable asset uses no suffix, matching the stable windows convention.

* fix(tools-pack): propagate --dir/--portable into containerized linux build

The inner `pnpm tools-pack linux build` invocation in `buildDockerArgs`
only forwarded `--to` and `--namespace`. Callers passing `--dir` (e.g.
the new release workflows using `--dir $RUNNER_TEMP/tools-pack`) had
their flag silently dropped: the container defaulted to writing under
/project/.tmp/tools-pack while the host's `findBuiltAppImage` looked at
the caller's chosen `--dir`, producing "expected AppImage not found"
on any non-default tool-pack root. Callers passing `--portable` had
the same drop, baking build-machine runtime roots into shipped artifacts.

Fix:
- Mount `${config.roots.toolPackRoot}:/tools-pack` (new third volume,
  alongside the existing /project, /home/builder, and cache mounts).
- Forward `--dir /tools-pack` to the inner build so its output lands
  inside the mounted host dir.
- Forward `--portable` when `config.portable` is true.

The mount overlaps harmlessly with /project when toolPackRoot lives
under workspaceRoot (default case): Docker exposes the same host inode
at both paths. The existing .docker-home and .docker-cache/* mounts
continue to shadow the parent at their specific /home/builder paths.

Document the shell-interpolation safety invariant on the inner command:
config.namespace is sanitized at config-time, config.to is enum-validated,
config.portable is boolean -- none can carry shell metacharacters.

Tests: add coverage for the new /tools-pack mount, --dir forwarding,
and --portable propagation (both true and false branches).

Resolves the P1 review feedback from the Codex bot on PR #369.

* docs(tools-pack): polish linux README based on PR review

Addresses non-blocking P2/P3 review feedback on PR #369:

- AppImage launch mode: name the test distros (Ubuntu 24.04, Arch Linux)
  and frame the FUSE-vs-extract-and-run gap as an order-of-magnitude
  improvement instead of an unspecified slowdown.
- Optional system tools: add a libfuse2 paragraph distinguishing FUSE
  launch (needs libfuse2) from extract-and-run (does not), with the
  Ubuntu-24-vs-pre-24 package name caveat.
- New section "Format choice: why AppImage first" anchoring the
  AppImage-only decision against industry precedent (VS Code, Discord,
  Slack, Cursor, Obsidian) so the rationale survives without a reviewer.
- Out of scope: convert the dense one-liner into a bulleted list, mark
  AppImage signing as gated on GPG infra + verification flow design
  (no ETA), explain the latest-linux.yml gap, and remove the now-stale
  "release lane" entry since this PR adds it.

* fix(tools-pack): add --appimage-extract-and-run to installed .desktop launcher

The XDG .desktop file installed by `tools-pack linux install` invoked
the AppImage directly via `Exec=env OD_NAMESPACE=<ns> <exec> %U`. That
bypassed the extract-and-run flag that `tools-pack linux start` applies,
so menu launches and `od://` desktop activations could hit the FUSE
slow path that was already shown to make the daemon sidecar exceed
apps/packaged's 35-second startup timeout. CLI-spawned starts succeeded
while menu-launched starts could fail with the same artifact.

Add `--appimage-extract-and-run` to the template's `Exec=` line and
update the renderDesktopTemplate test expectation. New regression test
locks the flag into place so a future template edit can't silently
drop it.

Resolves a P1 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): treat signal-terminated container builds as failures

`runBuildInContainer` resolved the build promise on `code === null`,
which in Node's child-process `exit` event means the child was
terminated by a signal (SIGTERM, SIGKILL, OOM-killer, parent process
death). A killed Docker build could therefore make `packLinux` report
a containerized build as complete even though the artifact was
partial or missing.

Accept the `signal` argument on the exit handler. Resolve only when
`code === 0 && signal == null`. Otherwise reject with a message
naming either the non-zero code or the terminating signal so the
failure mode is visible in CI logs and `tools-pack linux build --json`
output.

Resolves a P1 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): tear down orphaned process tree on failed linux start

If `startPackedLinuxApp` spawned the AppImage but the post-spawn
readiness path then failed -- either because the 60s waitForMarker
ceiling elapsed without the daemon writing desktop-root.json, or
because fetchDesktopStatus threw -- the detached child was left
running. Because the marker is the only persistent identity source
used by `stopPackedLinuxApp`, future lifecycle commands could not
associate the orphan with the namespace, leaving stale Electron and
sidecar processes plus stale IPC sockets that would interfere with
subsequent starts.

Wrap the readiness wait + status fetch in try/catch. On failure,
collect the spawned child's process tree via listProcessSnapshots +
collectProcessTreePids and stopProcesses() it (the same path
stopPackedLinuxApp uses for its tree teardown), then rethrow the
original error. Cleanup errors are swallowed so the original failure
is preserved in the rejection.

Extract the tree-teardown helper as `teardownOrphanedStart` so the
intent is documented at the call site without inlining 4 imports of
implementation detail.

Resolves a P2 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): use `corepack pnpm` in containerized linux build

The inner command in `buildDockerArgs` started with `corepack enable`,
which writes pnpm/yarn/npm shims into the directory containing the
node binary. In `electronuserland/builder:base`, that directory is
owned by root, but the container runs as the host's non-root uid via
`--user` (so build artifacts come out owned by the caller, not root).
The `corepack enable` step therefore fails with EACCES before
`pnpm install` ever runs, blocking the new release `build_linux` job
from publishing the Linux AppImage.

Switch to `corepack pnpm install --frozen-lockfile && corepack pnpm
tools-pack linux build ...`, which resolves and runs the version of
pnpm pinned in package.json's `packageManager` field directly. No
shims, no global mutation, no root writes — corepack just dispatches
to the pinned binary as the unprivileged user.

Update the existing inner-command test to match the new corepack
invocation, and add a regression test that asserts the inner command
contains `corepack pnpm` and never `corepack enable` so a future edit
can't reintroduce the root-write requirement.

Resolves a P1 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): accept menu-launched processes in linux stop/uninstall

stopPackedLinuxApp validated the live process via matchesStampedProcess
against the process command line, requiring a SIDECAR_SOURCES.TOOLS_PACK
stamp. That worked for `tools-pack linux start` (which spawns with
createProcessStampArgs), but rejected menu launches: the installed
.desktop entry only sets OD_NAMESPACE and does not pass stamp args, so
apps/packaged falls back to a SIDECAR_SOURCES.PACKAGED stamp written
into desktop-root.json -- a perfectly valid identity, just not the one
the validator accepted.

Symptoms with the old behavior:
  - `tools-pack linux stop` reported `unmanaged` for menu-launched apps
    and refused to stop them.
  - `tools-pack linux uninstall` would happily remove the AppImage,
    .desktop entry, and icon while the packaged app was still running,
    breaking handles to the AppImage's mounted/extracted contents.

Switch the validator to read marker.stamp directly (the file content
written by apps/packaged itself, not the process command) and accept
either TOOLS_PACK or PACKAGED. The expected app/mode/namespace/ipc
fields are still required to match. Mirrors the dual-source acceptance
pattern in mac.ts:709-714.

The matchesAppImageProcess (cmdOk) and namespaceRoot checks are
preserved -- the marker still has to point at our AppImage at a path
in our namespace's runtime root.

Drop the now-unused matchesStampedProcess import.

Resolves a P1 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): per-platform --to help text in CLI

addBuildOptions is shared across mac/win/linux but its --to help text
hard-coded the mac targets (all|app|dmg|zip), so:
  - tools-pack linux --help advertised --to all|app|dmg|zip even
    though resolveToolPackBuildOutput accepts only all|appimage|dir,
    sending users at invalid targets and hiding the AppImage option.
  - tools-pack win --help had the same problem (advertised mac
    targets while accepting all|dir|nsis with default nsis).

Parameterize addBuildOptions(command, platform) and back it with a
TO_HELP_BY_PLATFORM table that mirrors the resolver's accepted targets
in config.ts. Update the three call sites.

Smoke verified by running --help for each platform:
  linux: all|appimage|dir (default: all)
  mac:   all|app|dmg|zip (default: all)
  win:   all|dir|nsis (default: nsis)

The misleading "--signed: build a signed/notarized mac artifact" line
on win/linux is left alone -- out of scope for this fix and not part
of the review feedback.

Resolves a P3 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): use OD_PACKAGED_NAMESPACE in installed .desktop launcher

The installed .desktop entry's Exec= line set OD_NAMESPACE=<ns>, but
apps/packaged/src/config.ts:9 reads namespace overrides from
OD_PACKAGED_NAMESPACE, not OD_NAMESPACE. The env assignment was a
silent no-op for menu launches: the packaged app fell back to whatever
namespace was baked into open-design-config.json at install time,
ignoring the namespace advertised in the .desktop file.

Practical effect: a .desktop launcher created for namespace "foo"
could end up running as the namespace baked into the AppImage's
shipped config (typically "default"), so installs created across
multiple namespaces could collide silently from menu launches. CLI
launches via `tools-pack linux start` were unaffected because they
pass the namespace through createSidecarLaunchEnv which targets the
correct env var.

Switch the template to OD_PACKAGED_NAMESPACE. Update the existing
renderDesktopTemplate test fixture/expectation, and add a regression
test that asserts the Exec= line uses OD_PACKAGED_NAMESPACE and never
the wrong OD_NAMESPACE name.

Resolves a P1 review finding from mrcfps/Looper on PR #369.

* fix(tools-pack): gate linux uninstall + cleanup on stop status

uninstallPackedLinuxApp called stopPackedLinuxApp first, then deleted
the AppImage / .desktop entry / icon unconditionally. cleanupPackedLinux
Namespace did the same with the output and runtime namespace roots.
Both ignored stop.status -- so when stop returned "partial" (some
processes survived SIGTERM->SIGKILL) or "unmanaged" (the running PID
failed marker validation), uninstall would yank the install files out
from under a still-running packaged app, breaking handles to the
mounted/extracted AppImage contents and leaving an orphan with stale
SQLite WAL files / log handles / IPC sockets.

Extract a small `isSafeToRemoveInstallFiles(stop)` helper that returns
true only for "stopped" or "not-running". Both uninstall and cleanup
short-circuit when it returns false:

  - uninstall reports "skipped-process-running" for each removal slot
    and "skipped" for the post-install hooks. Existing "ok" / "already-
    removed" / "ok"|"missing"|"failed" paths are unchanged.
  - cleanup leaves both removed* booleans false and adds a new
    `skipped: boolean` field set to true. Old consumers that only read
    the booleans see the same "nothing was removed" signal they would
    have seen for an already-clean namespace; new consumers can
    distinguish "nothing to remove" from "refused to remove."

LinuxUninstallResult.removed.{appImage,desktop,icon} now also accepts
"skipped-process-running"; LinuxUninstallResult.postUninstall.* now
also accepts "skipped". LinuxCleanupResult gains the `skipped` field.
Workspace typecheck clean -- the only consumer is the CLI's printJson,
which doesn't constrain the wire shape.

Resolves a P1 review finding from mrcfps/Looper on PR #369.
2026-05-04 00:49:00 +08:00
lefarcen
016c08183f
release: Open Design 0.3.0 2026-05-03 23:07:28 +08:00
Sid
648374d839
fix(platform): wrap cmd.exe shim invocations to survive /s /c quote stripping (#339)
PR #258 standardized agent spawning through `createCommandInvocation`,
which on Windows wraps `.cmd` / `.bat` paths in `cmd.exe /d /s /c <line>`
and quotes each argument with cmd-style doubled quotes. PR #232's
follow-up fix for `shell:true` was lost in that refactor, and the new
shape has its own quoting bug on argv-style spawn:

1. cmd.exe `/s /c` strips exactly one leading and one trailing `"` from
   the rest of the command line.
2. Node, with `windowsVerbatimArguments` unset, escapes each argv element
   using CommandLineToArgvW rules — so the inner `"path with space"`
   ends up surfacing to cmd.exe with an extra layer of `\"` escaping
   that cmd doesn't understand.

Together these collapse `"C:\Users\Ethical Byte\...\codex.CMD" --help`
into `C:\Users\Ethical Byte\...\codex.CMD --help` with no quoting
preserved, and cmd.exe parses the first space as a token boundary —
"`Ethical` is not recognized as an internal or external command." See
issue #315 for the full repro.

The fix mirrors what Node's own `child_process.spawn({ shell: true })`
does internally: wrap the entire joined command line in an extra `"…"`
and set `windowsVerbatimArguments: true`. The outer wrap absorbs the
`/s /c` strip, leaving inner per-arg quoting intact, and the verbatim
flag tells Node to pass argv through to CreateProcess unchanged.

Changes:

- `packages/platform/src/index.ts`
  - Extend `CommandInvocation` with optional `windowsVerbatimArguments`.
  - Extract the cmd.exe shim builder into `buildCmdShimInvocation` and
    apply the outer wrap + verbatim flag in both `createCommandInvocation`
    and `createPackageManagerInvocation`.
  - Forward the flag through `spawnBackgroundProcess` and
    `spawnLoggedProcess`.
- `apps/daemon/src/server.ts` — agent spawn forwards
  `invocation.windowsVerbatimArguments`. This is the call site that
  hit #315 in the wild (Codex CLI `.CMD` shim, user dir with space).
- `tools/pack/src/win.ts` — `runPnpm` and `runNpmInstall` forward the
  flag through `execFileAsync`. Affects the Windows packaged-build
  pipeline when run from a path with spaces.
- `tools/dev/src/index.ts` — `runLoggedCommand` accepts and forwards the
  flag; `buildDesktop` propagates it from
  `createPackageManagerInvocation`. Affects local dev on Windows.

Tests:

- 9 new unit tests in `packages/platform/src/index.test.ts` stub
  `process.platform` so both Windows and POSIX branches run on every
  CI runner. Coverage:
    - POSIX pass-through.
    - Windows non-shim binary pass-through.
    - `.CMD` shim with spaces in the binary path (the #315 repro).
    - `.bat` shim parity.
    - Argv elements with spaces alongside the shim path.
    - Argv elements without whitespace stay unquoted.
    - `process.env.ComSpec` fallback.
    - `npm_execpath` short-circuit (cross-platform).
    - POSIX pnpm pass-through.
    - Windows pnpm wrapped through cmd.exe.

Closes #315.
2026-05-03 10:00:46 +08:00
lefarcen
62b01a6dbf
release: Open Design 0.2.0 (#297) 2026-05-02 22:28:59 +08:00
Kevin Tsai
c0589ed05e
fix(desktop): launch reliably on Windows from Electron-based parent shells (#292)
* fix(tools-dev): strip ELECTRON_RUN_AS_NODE before spawning desktop

Parent processes such as Electron-based IDEs may set ELECTRON_RUN_AS_NODE=1
in their environment for sidecar/script reuse. When tools-dev inherits this
env via process.env, the spawned electron.exe runs as plain Node and fails
to inject main-process APIs (app, BrowserWindow, protocol all become
undefined). Explicitly drop the variable before spawning so desktop always
boots in real Electron mode regardless of caller environment.

* fix(desktop): ensure BrowserWindow is visible on initial load

Windows focus-stealing prevention can leave detached-spawned GUI windows
minimized or hidden, even when constructed with show:true. Add a small
ensureWindowVisible helper that restores from minimized state and forces
show+focus after the placeholder URL loads. Cross-platform safe: only
acts when window is actually hidden or minimized, preserving any user
window-state adjustments.
2026-05-02 22:28:56 +08:00
Marc Chan
0e166bb799
Optimize Electron release artifact size (#249) 2026-05-02 14:22:16 +08:00
Tom Huang
1edab990bb
feat(craft): add brand-agnostic craft references + Refero-derived lint rules (#225)
* feat(craft): add brand-agnostic craft references and refero-derived lint rules

Introduce `craft/` as a third top-level content axis alongside `skills/`
and `design-systems/`, holding universal (brand-agnostic) craft rules
that apply on top of any DESIGN.md. Skills opt in via a new
`od.craft.requires` front-matter array; the daemon resolves the slug
list and injects the matching files between DESIGN.md and the skill
body in the system prompt.

Initial vendor (MIT, adapted from referodesign/refero_skill): typography
craft, color craft, anti-ai-slop. Pilot wired on saas-landing.

Extend the existing lint-artifact pass with two refero-derived rules:
- P0 ai-default-indigo — solid #6366f1 / #4f46e5 / #4338ca / #8b5cf6 as
  accent (not just gradients) is the most-reported AI tell.
- P1 all-caps-no-tracking — `text-transform: uppercase` rules without
  ≥0.06em letter-spacing.

The craft loader silently drops missing files so a skill can
forward-reference future sections (e.g. `motion`) without breaking.

* fix(daemon): skip :root token blocks in ai-default-indigo lint

The ai-default-indigo P0 check scanned the whole HTML for the raw
hex, so brands that intentionally encode indigo as `--accent: #6366f1`
in :root and consume it via var(--accent) downstream were flagged
as AI-default — a false positive that forced the agent to "fix"
valid output. Strip :root token-definition blocks (including
attribute-selector theme variants) before scanning, mirroring the
existing pattern used by the raw-hex P1 check. Hex still flagged
when it appears in component rules or inline styles.

* docs(craft): address PR #225 P3 review feedback

- craft/README.md: explain why missing craft sections are silently
  dropped (forward-compatibility) instead of surfacing a warning.
- craft/typography.md: ground the 0.06em ALL CAPS tracking floor in
  Bringhurst-derived typographic practice rather than presenting
  the threshold as unattributed.
- craft/color.md: cover the edge case where a brand's DESIGN.md
  intentionally encodes indigo as --accent — `var(--accent)` uses
  remain unflagged because the linter only inspects hardcoded hex.
- docs/skills-protocol.md: link the "missing files dropped silently"
  note back to craft/README.md for the canonical slug list and the
  rationale behind the choice.

* fix(craft): address PR #225 P0 review feedback

- tools/pack: copy `craft/` into the packaged resource root alongside
  `skills`, `design-systems`, and `frames`, so the `od.craft.requires`
  integration isn't a silent no-op when the daemon resolves
  `${OD_RESOURCE_ROOT}/craft` in packaged builds.
- packages/contracts: add `craftRequires?: string[]` to `SkillSummary`
  (and therefore `SkillDetail`) so the field that `listSkills()`
  already returns and `/api/skills(/:id)` already serializes via
  `...rest` is part of the documented web/daemon contract instead of
  leaking through as an untyped property.
- apps/daemon/lint-artifact: expand the indigo token-strip pass to
  cover selector lists containing `:root` (e.g. `:root, [data-theme="light"]`)
  and any rule whose body is custom-property-only (e.g. a
  `[data-theme="dark"] { --accent: ... }` theme variant). Real
  component rules with a hardcoded indigo are still preserved so the
  P0 finding still fires; tests cover the new selector-list and
  theme-variant cases.

* fix(craft): address PR #225 follow-up review feedback

- lint-artifact: scope the indigo token-strip to <style> blocks so the
  rule-shaped regex no longer captures leading `<style>` text into the
  selector (which broke `:root` recognition for token blocks that mix
  `color-scheme`/etc. with `--accent`). Run the strip on the extracted
  CSS instead, with a regression covering `:root { color-scheme: light;
  --accent: #6366f1 }`.
- lint-artifact: tighten the custom-property-only exemption to global
  theme-scope selectors (`:root`, `html`, `body`, bare attribute
  selectors like `[data-theme="dark"]`). Component-local rules such as
  `.cta { --cta-bg: #6366f1 }` are no longer exempted, so an agent
  cannot launder default indigo through a local var. Regression test
  added.
- craft/anti-ai-slop.md: stop claiming every rule below is enforced by
  the linter; only several are. The unenforced rules (standard
  Hero→Features→Pricing→FAQ→CTA flow, decorative blob/wave SVG
  backgrounds, perfect symmetry) are now flagged inline as
  "(guidance, not auto-checked)" so the contract with the lint surface
  stays honest.

* fix(daemon): tighten lint-artifact iteration and :root token gating

- all-caps-no-tracking: iterate every <style> block. The previous
  check called `exec` once on a non-global regex, so an artifact
  whose offending uppercase rule sat in a second <style> block
  (e.g. a reset block followed by a components block) slipped
  past. Switch to `matchAll` and break across both loops once a
  violation is found. Regression test covers a second-block
  uppercase rule.
- ai-default-indigo: stop unconditionally exempting any selector
  list containing `:root`. The exemption now requires both
  conditions to hold: every selector in the list is global theme
  scope AND the body is token-shaped (CSS custom properties or
  the `color-scheme` keyword). So `:root { background: #6366f1 }`
  and `:root, .cta { --cta-bg: #6366f1 }` no longer launder a
  hardcoded indigo through the strip pass. Regression tests cover
  both bypass shapes.

* fix(daemon): scope theme-attr exemption and strip CSS comments in token blocks

Address PR #225 review feedback on `ai-default-indigo`:

- The bare-attribute branch of `selectorListIsGlobalThemeScope` accepted
  any `[attr=...]` selector, so a custom-property-only rule on a
  component/state attribute (e.g. `[data-variant="primary"]`,
  `[aria-current="page"]`) was treated as a global theme block and
  stripped before the indigo scan — exactly the component-local indigo
  laundering this lint is meant to catch. Restrict the exemption to a
  small allowlist of known theme switches: `data-theme`,
  `data-color-scheme`, `data-mode`.
- `stripTokenBlocksFromCss` split rule bodies on `;` and matched each
  fragment from the start, so a token block whose body contained a
  normal CSS comment such as `:root { /* brand accent */ --accent:
  #6366f1; }` produced a fragment beginning with the comment, failed
  `isTokenShapedDeclaration`, and the rule was left in scope of the
  indigo scan — a false P0 on a legitimate token definition. Strip CSS
  comments before splitting/classifying declarations.

Add regression coverage: arbitrary component/state attribute selectors
still trip `ai-default-indigo`; `data-color-scheme` theme variants stay
exempted; `:root` token blocks with leading, trailing, and
between-declaration CSS comments are recognized.

* fix(daemon): strip CSS comments and recognize tokens nested in at-rules

The all-caps-no-tracking scan ran against raw `<style>` content, so a
commented-out rule like `/* .eyebrow { text-transform: uppercase; } */`
matched `upperRe` and emitted a P1 for CSS the browser ignores. Strip
CSS comments from the style body before structural matching.

`stripTokenBlocksFromCss` only matched flat `selector { body }` rules,
so a media-query-wrapped token block like
`@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }`
had its outer `@media` rule treated as the selector/body pair and the
inner `:root` token block was never stripped, producing a P0 false
positive on legitimate responsive theme CSS. Tighten the body
alternation to `[^{}]*` so the regex matches innermost rules and
recognizes the inner `:root` block directly while preserving the
outer at-rule wrapper.

* fix(daemon): align ai-default-indigo list with documented cardinal sins

The lint's AI_DEFAULT_INDIGO subset omitted #3730a3 and #a855f7, which
craft/anti-ai-slop.md lists as P0-blocked solid accents. An artifact
could hard-code one of those documented colors as a button fill and
slip past the indigo scan unless it happened to be inside a gradient.

Bring the lint set to the exact list documented in the craft doc, and
tighten the doc's wording from "etc." to an explicit enumeration that
points at AI_DEFAULT_INDIGO so the prompt contract and daemon behavior
stay in sync. Add regression tests pinning each newly-included hex.

* fix(daemon): tighten theme-scope selector and scan inline ALL CAPS

The theme-scope exemption used to accept any attribute on `:root`,
`html`, or `body` (e.g. `:root[data-variant="primary"]`), letting an
agent launder default indigo through a component/state attribute and
slip past the `ai-default-indigo` lint. The prefixed branches now
require the attribute name to be one of GLOBAL_THEME_ATTRIBUTES,
matching the bare-attribute branch.

The `all-caps-no-tracking` rule only iterated `<style>` blocks, so
inline declarations like `<span style="text-transform: uppercase">`
produced no finding even though craft/typography.md treats the
≥0.06em tracking floor as having no exceptions. Added a second scan
over `style="..."` attributes that runs the same letter-spacing
check and dedupes against the existing `<style>`-block finding so
the agent gets a single corrective signal per artifact.

* fix(daemon): align uppercase tracking px floor with the 0.06em rule

The previous absolute fallback (>=1.5px) was stricter than the craft
rule it enforces. `font-size: 12px; letter-spacing: 1px` is 0.083em
— above the 0.06em floor — but 1.5px would reject it and trigger an
unnecessary correction loop on compliant small-label CSS.

Extract `hasAdequateUppercaseTracking`: read `font-size` from the same
rule body and compare px tracking against `fontSize * 0.06`; fall back
to a conservative >=1px floor when font-size is inherited (covers the
default 16px body where 1px ≈ 0.0625em). Apply the helper to both the
<style>-block scan and the inline-style scan, and add 12–14px label
tests in both branches.

* fix(daemon): treat rem letter-spacing as absolute, not per-element em

`rem` was previously folded into the same branch as `em` and accepted
at the 0.06 threshold. But `rem` is relative to the root font-size
(16px default), not the element's own font-size, so on a 48px heading
`letter-spacing: 0.06rem` resolves to 0.96px — about 0.02em of the
element, well below the 0.06em rule the lint enforces.

Convert rem to absolute px through the 16px root assumption and reuse
the same px-vs-element-font-size resolution: same-rule `font-size: <n>px`
gives an exact `n * 0.06` floor; otherwise the conservative >=1px
fallback applies. Add regression tests for 48px headings with 0.06rem
tracking (must flag) plus the 16px-element and rem-floor matches that
must keep passing, in both <style>-block and inline-style branches.

* fix(daemon): resolve var() refs in uppercase tracking lint

`hasAdequateUppercaseTracking` only matched literal numeric values,
so a tokenized rule like `letter-spacing: var(--caps-tracking)` —
exactly the pattern the craft prompt steers artifacts toward — was
falsely reported as `all-caps-no-tracking`. Extract `--name: value`
declarations from global theme scopes (`:root`, `html`, theme-attribute
selectors) once per artifact, then expand simple `var(--name)` (and
`var(--name, fallback)`) references in the inspected rule body before
applying the existing 0.06em / px-floor / rem-conversion logic.
References without a matching token and no fallback stay in place,
preserving the conservative "missing tracking" finding.

* fix(daemon): resolve rem and var() font-size in uppercase tracking lint

Previously the px-vs-element-font-size resolution only matched
`font-size: <n>px`. Any rem-based or tokenized display size fell
through to the lenient `>= 1px` body-text fallback, so an artifact
emitting `.display { font-size: 3rem; text-transform: uppercase;
letter-spacing: 1px; }` (a ~48px heading with a 2.88px floor) slipped
past the lint that this helper exists to enforce.

Resolve `rem` font-size via the same root-font assumption already used
for tracking, and treat any explicitly declared but unresolvable unit
(`em`, `%`, `calc(...)`, an unresolved `var(...)`) conservatively —
refuse the lenient fallback so the rule must use either an `em`
letter-spacing or a verifiable px/rem font-size.

`var()` font-size declarations resolve through the existing
`resolveCssVars` pass before the size scan runs, so the same fix
catches the tokenized-display-size pattern (`--display-size: 3rem`).

* fix(daemon): parse declarations to ignore custom-prop names in uppercase tracking lint

The hasAdequateUppercaseTracking and resolveFontSizePx helpers used substring regexes against the rule body, so a token-name declaration such as `--letter-spacing: 0.08em` or `--display-font-size: 48px` could satisfy the `letter-spacing` / `font-size` checks even though it has no rendered effect — letting actual ALL-CAPS-without-tracking rules slip past the P1 lint.

Parse the declaration list, compare exact property names, and skip declarations whose property starts with `--`. Adds regression tests covering token-name letter-spacing (style-block + inline) and a token-name font-size masking the bail-out branch.

* fix(daemon): scope indigo token exemption to --accent only

Previously stripTokenBlocksFromCss removed every custom-property-only
global theme block before the ai-default-indigo scan, which let a
laundered indigo token like `:root { --primary: #6366f1 }` consumed
via `var(--primary)` slip past the lint. The craft contract is that
the only escape hatch is encoding indigo as the design system's
`--accent` token; any other token name is still the LLM-default
color hidden behind an arbitrary name. Narrow the strip pass so a
non-`--accent` token whose value carries an AI-default indigo hex
keeps the rule in scope, and add regression tests for `--primary` /
`--button-bg` global tokens feeding a CTA, including the at-rule
and theme-attribute variants.

* fix(daemon): model CSS cascade in tracking lint and detect blue→cyan trust gradients

Address PR #225 review feedback (3 comments):

- `letter-spacing` / `font-size` selection now picks the LAST matching
  declaration in the rule body, modeling CSS source-order cascade.
  `.eyebrow { letter-spacing: 0.08em; letter-spacing: 0.02em }` renders
  the noncompliant 0.02em the browser actually shows; the previous
  first-match behaviour silently passed it.
- `extractCssTokens` now records every distinct value seen for a token
  across global theme scopes, and `hasAdequateUppercaseTracking`
  enumerates each combination so a default-theme value below the floor
  cannot be rescued by a scoped override that happened to be parsed
  later (`:root { --caps-tracking: 0.02em }` +
  `[data-theme="dark"] { --caps-tracking: 0.08em }` now fires).
- New `trust-gradient` P0 rule pairs blue/sky tokens against cyan
  tokens in `linear-gradient(...)` bodies so `blue→cyan` two-stop
  trust gradients (documented as a cardinal sin in
  `craft/anti-ai-slop.md`) are actually enforced — both the hex form
  (`linear-gradient(90deg, #3b82f6, #06b6d4)`) and the keyword form
  (`linear-gradient(90deg, blue, cyan)`).

Adds 11 regression tests covering each path (cascade override in
<style> and inline form, font-size cascade shifting the floor, both
orderings of the conflicting-token cascade, the don't-over-fire case
when every theme value clears the floor, hex / keyword / sky variants
of the trust gradient, and the don't-double-fire case when
purple-gradient already caught a mixed gradient).

* fix(daemon): apply per-scope cascade in extractCssTokens

When the same CSS custom property is declared more than once inside a
single rule body (e.g. `:root { --caps-tracking: 0.02em;
--caps-tracking: 0.08em }`), CSS source-order cascade collapses to the
last value; the earlier declaration never reaches any element.
`extractCssTokens` was treating intra-scope duplicates as simultaneous
theme alternatives, so `hasAdequateUppercaseTracking` enumerated the
stale 0.02em and emitted a spurious all-caps-no-tracking finding.

Collapse duplicate token declarations within a rule body to the last
value before merging into the cross-scope distinct-value map. Cross-scope
overrides (separate `:root` and `[data-theme]` rules) remain preserved
as distinct values so the conservative theme-cascade check still fires
when ANY applicable theme renders below the floor.

* fix(daemon): scope tracking lint to innermost rules and per-theme tokens

Restrict the upperRe body alternation to [^{}]* so the regex matches
innermost CSS rules and skips at-rule wrappers — an outer @media or
@supports could otherwise capture as a single rule whose selector was
the at-rule and whose body began with the inner selector token, masking
the same-rule font-size and letting noncompliant tracking on large
headings slip through the lenient inherited-size fallback.

Replace the by-name-distinct-values token map with per-scope token
records and a buildResolvedThemes pass that materializes one effective
map per theme. Paired token declarations now stay paired during
evaluation, so theme variants like :root + [data-theme=dark] no longer
generate cross-theme cartesian pairings (e.g. default-size + dark-track)
that emit false positives on legitimate light/dark themes.

---------

Co-authored-by: looper <looper@open-claude.dev>
2026-05-02 11:00:33 +08:00
Foximo24
a4fd4f949f
fix(tools-dev): use junction instead of dir symlink on Windows (#231)
ensureWebDevNodeModules() called fs.symlink(target, path, "dir") to
link apps/web/node_modules into the web runtime root. On Windows,
"dir" symlinks require either Administrator rights or Developer Mode
(SeCreateSymbolicLinkPrivilege). Standard non-elevated user accounts
without Developer Mode get EPERM and tools-dev exits before web ever
starts.

Junctions ("junction") are functionally equivalent for directory-only
links on the same volume, work for any user without elevation or
Developer Mode, and are silently treated as plain symlinks on POSIX.
The existing isSymbolicLink() check on the next launch still matches
junctions on Node.

Reproduced on Windows 11 + Node 24 with a non-elevated PowerShell
session and Developer Mode off.
2026-05-02 10:17:43 +08:00
yamsfeer
4510c69ba1
feat(tools-dev): add --prod flag and OD_HOST for headless server deployment (#222)
* feat(tools-dev): add --prod flag and OD_HOST for headless server deployment

- Lazy-load electronBinaryPath so daemon+web can start without Electron
- Add --prod flag to tools-dev start/run that sets NODE_ENV, OD_WEB_PROD,
  and OD_WEB_OUTPUT_MODE automatically for production Next.js builds
- Add OD_HOST env var support to daemon and web sidecar bind addresses
  (defaults to 127.0.0.1, set to 0.0.0.0 for remote access)
- Skip next.config.ts distDir override when OD_WEB_PROD=1 so production
  builds resolve the default .next directory

Closes #221

* fix: address PR review — hardcode daemon bind to 127.0.0.1, cache electron path

- Revert OD_HOST from daemon: daemon is a local privileged process and must
  only bind 127.0.0.1. Remote access goes through the web sidecar proxy.
- Web sidecar resolveDaemonOrigin now always uses 127.0.0.1, separated from
  the web bind address (OD_HOST) so the daemon proxy works correctly even
  when the web listener binds 0.0.0.0.
- Add OD_HOST character validation to reject clearly invalid values early.
- Cache electronBinaryPath getter result to avoid repeated require() calls.

---------

Co-authored-by: yamsfeer <yamsfeer@users.noreply.github.com>
2026-05-02 09:27:16 +08:00
PerishFire
f604ff1ec2
Add Windows beta packaging and release assets (#191) 2026-05-01 16:46:15 +08:00
Waleed978
89722379c5
fix(tools-dev): normalize web dev tsconfig paths on Windows (#174)
tools-dev generated a temp web tsconfig with Windows backslash relative paths in extends, which Next/TypeScript failed to resolve in some environments. Normalize runtime tsconfig/dist path strings to POSIX separators so dev config resolution works consistently across Windows/Linux/macOS.
2026-04-30 22:45:02 +08:00
PerishFire
a40d817d28
Add mac packaged runtime and beta release flow (#170)
* feat(pack): add mac packaged runtime control plane

* feat(pack): harden mac packaged runtime lifecycle

Keep packaged state namespace-scoped, make daemon paths explicit through sidecar launch env, and add conservative desktop identity/logging fallbacks for local mac package validation.

* feat(pack): add mac beta release flow

* fix(pack): generate mac update feed fallback

* fix(pack): write portable beta checksums

* fix(pack): make beta artifacts portable

* fix(pack): clean up mac install visuals

* fix(pack): address packaged runtime review feedback
2026-04-30 20:25:49 +08:00