Commit Graph
186 Commits
Author SHA1 Message Date
pftom 31c0bd159f fix(research): require canonical SHA on install, validate memory tags
- installHubEntry now requires entry.source.commitSha to match
  FULL_COMMIT_SHA_PATTERN (40 hex chars). The shorter prefix shape was a
  trust gap: an in-process caller could craft an entry whose commitSha
  matched the approval string by luck, pass the equality check, and
  trigger a re-fetch with ?ref=<short> that GitHub resolves through
  branch/tag names.
- createMemory now validates input.tags as a bounded array of slug
  strings (max 16 entries, 64 bytes each, 512 bytes total) and rejects
  control characters and prompt-frame tokens, so a future memory route
  cannot bypass the 8KB content cap by smuggling instruction-like
  strings through tags that buildMemoryPrefix renders into the prompt
  JSON.
- Added regression tests for both gates.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
2026-05-07 09:12:34 +08:00
pftom 8ff084cf44 fix(research): thread server-owned token to installHubEntry re-fetch
`installHubEntry` re-fetches the manifest at the approved commit SHA
before writing, but the call into `fetchManifestRawAtSha()` did not pass
any token. `HubEntry.source` deliberately records `repo/path/commitSha`
without a token (forwarding an entry must not exfiltrate credentials),
so for a hub listed through `HubConfig.token` / `OD_HUB_TOKEN` the
re-fetch hit the GitHub API anonymously and returned 404 — breaking the
in-process install path documented as usable for entries from
`listHubEntries()` against a private hub (PR #617 review, P1).

`InstallOptions` now carries an optional server-owned `token`. The
install path prefers `opts.token` and falls back to `OD_HUB_TOKEN` when
the caller leaves it unset, mirroring the env-var contract
`listHubEntries()` already honors. The token is intentionally kept
out-of-band on the options object so a forwarded `HubEntry` still cannot
carry credentials across a process boundary; callers that don't already
hold a server-owned token should route through `installHubManifest`,
which takes the token on the selector instead.

Adds two regression cases to
`apps/daemon/tests/research-skill-hub-install.test.ts`:

- `threads opts.token through the re-fetch so private-hub entries
  install` — the mock returns 404 unless the bearer token matches, so
  the test fails if the install path forgets to thread the token.
- `falls back to OD_HUB_TOKEN when opts.token is not supplied` — covers
  processes configured the same way as the listing call site.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 14:37:28 +08:00
pftom 624820f192 fix(research): partition skill-hub cache, re-fetch on install, bound audit lines
Addresses four PR #617 review threads:

- listHubEntries cache key now partitions on a per-process HMAC fingerprint
  of the bearer token (or the literal "public" sentinel for tokenless
  requests). This stops a tokened request that surfaces private hub
  manifests from being served back to a later tokenless request with the
  same (repo, ref, namespace), where `entry.raw` is prompt material.
- installHubEntry no longer trusts caller-supplied `entry.raw`. After the
  SHA equality check it re-fetches the manifest from
  `/repos/.../<entry.source.path>?ref=<entry.source.commitSha>` and
  writes only the freshly fetched bytes; if those bytes drift from
  `entry.raw` the install is rejected. A new `installHubManifest` entry
  point takes a server-owned selector `{ namespace, name, repo,
  pinnedSha }` and is the recommended surface for any future
  `POST /api/skill-hub/install` route — HTTP bodies never carry
  manifest bytes through the writer.
- The audit-log redaction pass now caps array length, object key count,
  and the final serialized JSONL line. Items past the per-container caps
  are replaced with a single truncation marker; lines past the byte cap
  collapse `detail` to a `__truncated` marker carrying the original size,
  so a pathological `detail` cannot block the daemon on synchronous
  JSON.stringify / appendFileSync.
- The Skill Hub example in `docs/research/openwork-aionui-alaude-
  integration.md` now mirrors the safe install path the starter enforces:
  pinned-SHA listing, `installHubManifest` taking a selector + approval,
  re-fetch inside the install path, the quarantine/activation boundary,
  and SHA-pinned sibling-asset fetches.

Adds `apps/daemon/tests/research-audit-log-bounds.test.ts` covering the
new caps, and extends the install test with a re-fetch / drift case plus
selector-based install coverage.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 14:15:58 +08:00
pftom 31399fefed fix(research): resolve hub pinnedSha to canonical commit SHA via GitHub API
`COMMIT_SHA_PATTERN` was treating any 7–40 hex string as immutable
provenance, but GitHub's `?ref=` parameter also accepts branch and tag
names. A hub repo could create a mutable branch/tag named like a short
SHA, pass the regex, fetch mutable-ref bytes, and then satisfy
`installHubEntry` because `entry.source.commitSha` and
`approval.pinnedSha` would both be the caller-supplied string (PR #617
review, P1).

`listHubEntries` now resolves the caller-supplied SHA prefix through
`GET /repos/{owner}/{repo}/commits/{input}` before any listing happens.
The canonical 40-char `.sha` is required to share the input's lowercase
hex prefix; otherwise GitHub resolved the ref through a branch/tag and
we refuse to treat it as immutable provenance. The canonical full SHA
is then used as the `?ref=` value and stamped on every
`HubEntry.source.commitSha`, so `installHubEntry`'s exact-match
equality compares 40-char canonical values on both sides.

Adds `apps/daemon/tests/research-skill-hub-resolve.test.ts` with three
cases: hex-shaped pinnedSha that resolves through a branch is rejected
before any Contents API call; a real prefix that resolves to a sharing
commit is accepted; and the listing/Contents API both use the canonical
40-char SHA, never the caller's raw 7-char input.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 13:48:55 +08:00
pftom 8f66221e0b fix(research): tie skill-hub install bytes to the approved commit SHA
`installHubEntry` previously accepted any SHA-shaped `approval.pinnedSha`
without verifying that the bytes in `entry.raw` came from that commit.
Because `listHubEntries` fetched through the configured mutable branch
ref and `HubEntry.source` carried no immutable provenance, a caller could
list from `main`, pass any SHA-shaped string with `userApproved: true`,
and still install unpinned branch content into active prompt directories.

This change carries provenance end-to-end:

- `HubConfig` accepts `pinnedSha` (or `OD_HUB_PINNED_SHA`); when set,
  `listHubEntries` / `readDirEntry` / `readFileEntry` route the GitHub
  Contents API through `?ref=<sha>` and stamp every returned
  `HubEntry.source.commitSha` with that SHA.
- The list cache key now hashes the resolved ref (SHA or branch) so a
  pinned listing can never collide with a branch-tip listing.
- `installHubEntry` now refuses any entry whose `source.commitSha` is
  missing (mutable-branch listing) or does not match
  `approval.pinnedSha` (case-insensitive exact equality, no prefix
  matching). The planning comment block is updated to reflect that the
  SHA-anchored install path is now enforced rather than future-tense.

Adds `apps/daemon/tests/research-skill-hub-install.test.ts` covering:
mutable-branch entry rejected, mismatched approval SHA rejected,
matched SHA accepted, case-insensitive equality, short-prefix SHA
rejected, untrusted `userApproved: false`, and malformed approval SHA.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 13:27:25 +08:00
pftom 02cd4c61e7 fix(research): expand skill-hub mode list and parse block-scalar descriptions
Frontmatter validation in the research skill-hub starter now mirrors the
shapes already used by `skills/*/SKILL.md`:

- `ALLOWED_OD_MODES` adds `image`, `video`, `audio`, and `utility` so
  media and utility skills (e.g. `image-poster`, `video-shortform`,
  `audio-jingle`, `pptx-html-fidelity-audit`) are no longer rejected by a
  hub mirroring the catalog.
- The minimal YAML parser now reads `description: |`, `description: |-`,
  `description: >`, and `description: >-` block scalars instead of
  storing the literal `|` / `>`. Continuation lines are joined with `\n`
  and right-trimmed, matching `apps/daemon/src/frontmatter.ts`.
- Adds `apps/daemon/tests/research-skill-hub-frontmatter.test.ts` with
  fixture coverage against representative live skills for every
  currently-used `od.mode` value plus inline cases for `>` / `|-`.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 13:17:12 +08:00
pftom c706866068 fix(research): redact camelCase secrets, gate hub install, daemon-side browser MCP
- audit-log: redact camelCase secret keys (`accessToken`, `refreshToken`,
  `clientSecret`, `sessionId`, etc.) by inserting separators at case
  transitions before pattern test (PR #617 review, P2).
- skill-hub: require explicit `InstallApproval { pinnedSha, userApproved }`
  on `installHubEntry` and document the trust boundary — manifest
  validation alone is not a trust step (PR #617 review, P1).
- research doc: rewrite browser-use plan to remove the false "all agents
  are sandboxed" / "MCP tool consent already standard" premises; require
  daemon-owned MCP proxy with default-deny, transport-level read-only
  allowlist, and per-tool UI approval (PR #617 review, P1).

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 13:07:32 +08:00
pftom 1c37080ed9 fix(research): bound audit redaction, reader limit, and memory prefix
Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 12:52:39 +08:00
pftom 0a12cdb1c9 fix(research): accept existing DESIGN.md / craft shape in skill-hub
Address PR #617 review comment PRRC_kwDOSOgY8s6-ULyy: the previous
frontmatter validator rejected the 140+ design systems and the craft
modules Open Design already ships, since those files use a plain
Markdown shape (`# <Heading>` plus `> Category:` blockquote) rather
than YAML frontmatter. A hub repo mirroring the existing artifacts
would have returned no entries from `listHubEntries()` and thrown
from `installHubEntry()`, which defeats the starter's stated purpose.

Add a heading-based fallback for `design-systems` and `craft`:

- `deriveHeadingDescription(raw)` reads the first `# ` heading and
  the first blockquote block (cap 280 chars), stripping any
  frontmatter that may have been present.
- `resolveManifest(namespace, raw, manifestId)` keeps `skills`
  strict (frontmatter required, since all 65+ shipped skills carry
  `name` / `description`) but accepts either frontmatter or the
  plain-Markdown shape for `design-systems` and `craft`. The
  manifest id is still re-validated against the namespace's slug
  rules to keep path-traversal guards intact, and any `name` or
  `od.mode` present in frontmatter must still be valid.
- `readDirEntry`, `readFileEntry`, and `installHubEntry` route
  through `resolveManifest` instead of `validateFrontmatter`.

`validateFrontmatter` itself stays as-is (still strict, still
exported) so the v0 schema remains documented for hubs that opt
into frontmatter.

Sanity-checked against `design-systems/apple/DESIGN.md`,
`design-systems/atelier-zero/DESIGN.md`, and
`craft/accessibility-baseline.md`: all three now resolve to
non-null entries with sensible derived descriptions, while
`resolveManifest('skills', appleRaw, 'apple')` still returns null.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 12:22:55 +08:00
pftom 897e477277 fix(research): reject "."/".." project ids and split audit-file resolver
Address two follow-up review comments on PR #617:

- isSafeProjectId: explicitly reject "." and "..". The existing character
  allowlist `[A-Za-z0-9._-]{1,128}` accepted bare-dot ids, and the
  defense-in-depth check still permitted `dir === projectsRoot`, so
  `projectId="."` would have written `audit.jsonl` directly under
  `<dataDir>/projects/`. Tighten the strict-prefix check to require a
  proper subpath of the projects root.

- Split path resolution from directory creation per reviewer's suggested
  shape: rename `resolveAuditPath` to `auditFilePath` (pure resolver,
  returns the full file path as a `string`) and have `ensureAuditFile`
  call it then `mkdirSync(path.dirname(file))`. `appendAuditEntry`
  continues to call ensure, `readAuditEntries` calls the pure resolver
  only — reads cannot create directories.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 12:10:23 +08:00
pftom 8d4e940183 fix(research): harden memory/audit/hub starters per PR review
Address P1/P2 security review feedback on the unwired research starters
under apps/daemon/src/research/:

- memory-schema.ts (P1 prompt injection): reject control characters and
  prompt-frame tokens at create/update time, render entries as
  <-escaped JSON in `buildMemoryPrefix`, and reword the wrapper to mark
  memory as user-provided context rather than authoritative instructions.
  Also clamp the LIMIT in `listMemoriesForContext` to a finite integer
  and bind it as `LIMIT ?` instead of interpolating.

- audit-log.ts (P1 path traversal + P2 redaction no-op): split path
  resolution from `mkdirSync` so reads are side-effect-free; add a
  recursive redactor that strips token / api_key / authorization /
  cookie / password / private_key / session_id / bearer / x-api-key
  fields before append and on export, with a per-string length cap and
  bounded recursion depth.

- skill-hub.ts (P2 download cap + frontmatter validation): replace
  `arrayBuffer()` with a streamed reader that bails on Content-Length
  over the cap and cancels mid-download once accumulated bytes exceed
  it; replace the description-only regex with a minimal frontmatter
  parser plus per-namespace validation (name slug + manifest-id match,
  description required, `od.mode` allowlist) applied on both list and
  install paths.

No runtime wiring changes; module remains unimported by server.ts.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 12:03:56 +08:00
pftom f7ffce26eb fix(research): guard project id in audit-log starter
Validate `projectId` against the same `^[A-Za-z0-9._-]{1,128}$` allowlist
that `apps/daemon/src/projects.ts#isSafeId` uses, and verify the resolved
audit directory stays under `<root>/projects/`, before `mkdirSync()`.
Prevents a path-traversal bug if this starter is wired to the planned
`GET /api/projects/:id/audit` route with a malformed `:id`.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 11:53:26 +08:00
pftom 4f23f34bf2 fix(research): respect includeArchived; install craft at root
Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-06 11:44:40 +08:00
pftom 2f55171595 docs(research): openwork / AionUi / alaude-desktop integration analysis
Adds a deep capability survey of three external Agent-client projects
and a minimum integration starter for Open Design.

- docs/research/openwork-aionui-alaude-integration.md: full analysis,
  capability matrix, P0/P1/P2 extraction list with rationale, roadmap,
  and pitfalls. Each item carries "why useful" + "why open-design needs
  it" + minimum landing form.
- apps/daemon/src/research/: standalone TypeScript starters for the
  three highest-ROI items (memory schema + injection helper, audit log
  JSONL writer/reader, GitHub-as-registry skill hub fetcher). NOT wired
  into server.ts; subdir README states the integration path for follow-
  up PRs (one PR per item, with contracts DTOs and tests).

Validation: pnpm guard + pnpm --filter @open-design/daemon typecheck.
2026-05-06 11:38:29 +08:00
2036ce0a8e feat(web): add Cmd/Ctrl+P quick file switcher (#556)
* feat(web): add Cmd/Ctrl+P quick file switcher

A keyboard-driven file palette overlaid on the workspace. Press Cmd/Ctrl+P
anywhere in the project view; type to fuzzy-filter the file list, ↑/↓ to
navigate, Enter to open in a tab, Esc to dismiss. With an empty query the
palette surfaces recents (per-project, localStorage) followed by the rest
of the file list sorted by mtime.

Adds:
- apps/web/src/components/QuickSwitcher.tsx: palette UI and matcher
- apps/web/src/quickSwitcherRecents.ts: per-project recents store
- index.css: scoped .qs-* styles using existing design tokens
- i18n: 6 new keys translated across all 16 locale files

Wires into FileWorkspace's existing openFile() so recents and tab state
behave identically to opening from DesignFilesPanel. Capture-phase keydown
beats the browser's print dialog. No backend changes; uses the files prop
already passed to FileWorkspace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): address QuickSwitcher review feedback

Three fixes from the PR review:

- z-index: bump .qs-overlay from 200 to 1500 so the palette renders in
  the modal tier (alongside prompt-template-modal-overlay) instead of
  behind context menus and popovers (which sit at 200).

- Arrow-key guard: skip setCursor when matches is empty. Without this,
  pressing ↓ on a no-results query set the cursor to -1, making the
  highlight selector miss every row on the next render.

- Tests: add 19 unit tests covering scoreMatch ranking tiers, render
  output (empty state / row count / kbd hints / placeholder), and the
  full recents lifecycle (cap at 6, dedupe-on-push, corrupt-JSON
  recovery, per-project scoping, quota-exceeded no-op). Vitest stays
  on the node env via a small in-memory localStorage stub.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): QuickSwitcher review — wrap, IME, platform gate

Three follow-ups from @mrcfps's review on #556:

- ArrowUp/ArrowDown now wrap at list bounds (last → first, first → last)
  via modulo arithmetic in a new pure helper `nextCursor(current, total,
  direction)`. Previously they clamped, which contradicted the wrap
  behavior the PR test plan promised. Pulled into a pure function so
  boundary cases are unit-testable without simulating keyboard events.

- Palette's onKeyDown now early-returns on `e.nativeEvent.isComposing`,
  so users typing CJK file names through an IME keep ↑/↓/Enter for
  candidate navigation instead of having them steered by the palette.
  The global Cmd/Ctrl+P opener already had the equivalent guard.

- Global keydown is now platform-gated: macOS responds only to metaKey,
  win/linux only to ctrlKey. Previously both fired everywhere, which
  meant Ctrl+P on macOS was stealing native readline "previous line" in
  text fields (and the chat composer).

Tests: +6 unit tests for `nextCursor` covering forward/backward wrap,
mid-list moves, empty list (no division-by-zero), and single-item
no-op. Suite now 258 passing (up from 252).

Verified live: ↓ from last row → first row; ↑ from first row → last
row, in a mocked-project Playwright harness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:31:50 +08:00
ferasbusiness666andGitHub e8b63ecec1 feat: add resizable chat panel (#563) 2026-05-06 10:12:45 +08:00
bojie.hbjandGitHub 29e1ed5f28 fix: 修复设计文件列表行菜单被裁剪的问题 (#561) 2026-05-06 00:46:18 +08:00
TomandGitHub 6b2792b03a fix(daemon): remove --no-session from pi adapter to persist session files (#557)
* fix(daemon): remove --no-session from pi adapter to persist session files

The pi agent was the only adapter explicitly passing `--no-session`
in its `buildArgs`, preventing pi from writing session files.
All other adapters either run in single-shot mode by design or use
the ACP JSON-RPC session lifecycle without suppressing persistence.

Removing `--no-session` lets `--mode rpc` retain its default behavior
of writing session state, which is needed for multi-prompt continuity
and matches the rest of the harness ecosystem.

* test(daemon): add pi buildArgs regression tests; fix docs for --no-session removal

- Adds test for pi.buildArgs base shape: returns ["--mode", "rpc"]
  and does not include --no-session (prevents regression).
- Adds test for --model and --thinking option passthrough.
- Updates pi-rpc.ts lifecycle comment to remove [--no-session].
- Updates README.md and all localized READMEs to reflect the
  corrected pi CLI invocation.
2026-05-06 00:26:09 +08:00
Marc ChanandGitHub 0953ab58a2 fix(web): todo card dark mode styling (#526)
* fix(web): improve todo card dark mode

* fix(web): address todo card contrast feedback

Generated-By: looper 0.5.5 (runner=fixer, agent=opencode)
2026-05-05 23:57:02 +08:00
lefarcenandGitHub 963bbf2500 release: Open Design 0.4.0 (#454) 2026-05-05 23:39:40 +08:00
ChildhoodAndyandGitHub 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
soulmeandGitHub 6b7a40e5c3 Fix file tab wheel scrolling (#549) 2026-05-05 23:23:48 +08:00
Justin GaoandGitHub cbe2baf596 feat(web): add skills & design systems management page in settings (#535)
* feat(web): add skills & design systems management page in settings

Add a new "Library" section in Settings that lets users browse, search,
preview, and enable/disable skills and design systems. Disabled items are
excluded from the create-project picker. Phase 1 — browse/toggle only.

Closes #497

* fix(web): persist empty disabled lists and deduplicate DS preview

Use empty array instead of undefined when all items are re-enabled so
the daemon merge clears the key. Move DS preview panel outside the
category group loop so it renders once, not per group.

* fix(web): address review feedback on library settings

Clear disabled lists on invalid daemon writes, memoize enabled item
filters in App.tsx, and guard preview fetch against rapid-click race
conditions.

* fix(web): hydrate disabled lists from daemon and keep full lists in ProjectView

Merge daemonConfig.disabledSkills/disabledDesignSystems during bootstrap
so the values survive localStorage resets. Pass unfiltered skills and
design systems to ProjectView so existing project metadata resolves
correctly.
2026-05-05 22:50:25 +08:00
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
79fcaef129 Add Tweaks mode for HTML previews with picker, pod selection, and batched chat attachments (#513)
* Add tweaks mode for HTML preview comments

* Fix tweaks geometry and restore critique migration

* Harden tweaks mode reload sync

* Guard tweaks batch sends during active runs

---------

Co-authored-by: puma <puma@pumas-MacBook-Air.local>
2026-05-05 21:09:20 +08:00
VioletandGitHub 34e8db175d fix(daemon): preserve ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is set (#514)
* fix(daemon): preserve ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is set

The claude adapter currently strips ANTHROPIC_API_KEY unconditionally
so that Claude Code's own auth resolution (claude login) wins instead
of silently falling back to API-key billing.

However, when ANTHROPIC_BASE_URL is set the user is intentionally
routing Claude Code to a custom endpoint (e.g. a Kimi/Moonshot proxy).
In that case claude login is meaningless, so preserve the API key so
the child can authenticate against the custom base URL.

* fix(daemon): guard against empty ANTHROPIC_BASE_URL values

Address review feedback: check that ANTHROPIC_BASE_URL contains a
non-empty, non-whitespace string before preserving ANTHROPIC_API_KEY.
This prevents the #398 billing guard from being bypassed when the
variable is set to '' or whitespace.
2026-05-05 21:02:49 +08:00
e0a75ea8c8 fix: add lowercase 'todowrite' compatibility in ToolCard (#523)
Add backward compatibility for historic OpenCode CLI data:
- Match both 'TodoWrite' and 'todowrite' tool names
- Fixes rendering of TodoWrite cards from persisted chat history

Scope: 1 line change, no other files modified

Co-authored-by: bojiehuang <bojiehuang@bojiehuangdeMacBook-Pro.local>
2026-05-05 19:36:48 +08:00
Marc ChanandGitHub c3d9136a0c Add live artifacts and Composio connector catalog (#381)
* docs: add live artifacts implementation spec

* docs: align live artifacts implementation plan

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 7: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 10: work in progress

* Ralph iteration 11: work in progress

* Ralph iteration 12: work in progress

* Ralph iteration 13: work in progress

* Ralph iteration 14: work in progress

* Ralph iteration 15: work in progress

* Ralph iteration 16: work in progress

* Ralph iteration 17: work in progress

* Ralph iteration 18: work in progress

* Ralph iteration 19: work in progress

* Ralph iteration 20: work in progress

* Ralph iteration 21: work in progress

* Ralph iteration 22: work in progress

* Ralph iteration 23: work in progress

* Ralph iteration 24: work in progress

* Ralph iteration 25: work in progress

* Ralph iteration 26: work in progress

* Ralph iteration 27: work in progress

* Ralph iteration 28: work in progress

* Ralph iteration 29: work in progress

* Ralph iteration 30: work in progress

* Ralph iteration 31: work in progress

* Ralph iteration 32: work in progress

* Ralph iteration 33: work in progress

* Ralph iteration 34: work in progress

* Ralph iteration 35: work in progress

* Ralph iteration 36: work in progress

* Ralph iteration 37: work in progress

* Ralph iteration 38: work in progress

* Ralph iteration 39: work in progress

* Ralph iteration 40: work in progress

* Ralph iteration 41: work in progress

* Ralph iteration 42: work in progress

* Ralph iteration 43: work in progress

* Ralph iteration 44: work in progress

* Ralph iteration 45: work in progress

* Ralph iteration 46: work in progress

* Ralph iteration 47: work in progress

* Ralph iteration 48: work in progress

* Ralph iteration 49: work in progress

* Ralph iteration 50: work in progress

* Ralph iteration 51: work in progress

* Ralph iteration 52: work in progress

* Ralph iteration 53: work in progress

* Ralph iteration 54: work in progress

* Ralph iteration 55: work in progress

* Ralph iteration 56: work in progress

* Ralph iteration 57: work in progress

* Ralph iteration 58: work in progress

* Ralph iteration 59: work in progress

* Ralph iteration 60: work in progress

* Ralph iteration 61: work in progress

* Ralph iteration 62: work in progress

* Ralph iteration 63: work in progress

* Ralph iteration 64: work in progress

* Ralph iteration 65: work in progress

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 17: work in progress

* Add Composio-backed connectors

* Add Composio-backed connector catalog

* Fix connector callback flow

* Update live artifact connector refresh

* Fix live artifact refresh updates

* Improve live artifact viewer toolbar

* Refine live artifact source tabs

* Expand Composio connector catalog

* Improve Composio connector browsing

* Fix artifact refresh source safety checks

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

* Fix live artifacts PR feedback

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

* Fix live artifact preview CORS validation

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

* Fix connector OAuth IPv6 loopback hosts

Allow bracketed IPv6 loopback Host headers when deriving connector OAuth callback URLs so IPv6-bound daemons can complete connection flow.

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

* Preserve live artifact refresh permissions

Respect explicit refresh permission choices during live artifact create and update flows so revoked connector sources remain gated.

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

* Fix live artifact preview cache freshness

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

* Fix live artifact refresh validation

Guard manual refreshes with local daemon checks and reject daemon_tool sources without a toolName before refresh execution.

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

* Fix Composio credential invalidation

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

* Fix live artifact CORS methods

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

* Fix workspace validation

Restore media config test isolation under Vitest setup data-dir overrides and add the missing French live artifact display copy so the workspace test suite stays aligned.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector safety filtering

Keep agent-preview connector listings aligned with execution safety policy and prune stale Composio OAuth state records before they accumulate.

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

* Fix agent runtime cleanup

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

* Fix live artifact daemon access

Validate local-only live artifact routes against the peer socket address and pass daemon-resolved CLI paths to ACP MCP descriptors.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector run limit pruning

Evict stale connector rate-limit buckets so long-lived daemon processes do not retain per-run entries indefinitely.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector compact schemas

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

* Improve connector connection feedback

* Adjust connector gate positioning

* Fix live artifact refresh commits

Avoid marking refresh candidates failed after snapshot or state persistence errors by deferring live artifact mutations until the durable refresh metadata is written. Also align connector OAuth callback host validation with daemon loopback handling.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Improve connector search relevance

* fix(daemon): harden connector connection state

Require loopback daemon validation before connector connect side effects and only clear provider-owned connector statuses during credential reset.

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

* fix(daemon): guard connector disconnect route

Require local daemon request validation before connector disconnect side effects.

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

* fix(daemon): guard composio config updates

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

* fix(daemon): dispatch live artifacts mcp first

Route the live-artifacts MCP server before the generic MCP CLI so od mcp live-artifacts starts the dedicated server instead of failing generic argument parsing.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): handle integer connector schemas

Allow JSON Schema integer connector inputs while preserving fractional-value validation so generated connector tool schemas accept valid page sizes and limits.

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

* fix: align live artifact refresh error codes

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

* Fix live artifact connector refresh flow

* Update live artifact design cards

* Add beta badge to live artifact form

* Remove live artifact tile model

* Fix live artifact refresh sync

* Fix live artifact MCP refresh durability

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

* Fix live artifact refresh safety

Enforce persisted refresh opt-out and connector auto-read gating before refresh sources execute.

Generated-By: looper 0.5.5 (runner=fixer, agent=opencode)
2026-05-05 16:42:11 +08:00
76e6c7a9f6 feat: Critique Theater Phase 4 (persistence + transcript + orchestrator) (#481)
* docs(specs): add Critique Theater design spec for panel-tempered artifacts

* docs(specs): add Critique Theater implementation plan

* docs(specs): rename UI to Design Jury, add lane-density modes, ship-rule explainer, label sizing

* feat(contracts): add CritiqueConfig schema and defaults

* fix(contracts): apply Task 1.1 review (CRITIQUE_PROTOCOL_VERSION rename, descriptions, RoleWeights export)

* feat(contracts): add PanelEvent discriminated union and isPanelEvent guard

* fix(contracts): apply Task 1.2 review (exhaustive event-type list, runId guard, import order)

* feat(contracts): add CritiqueSseEvent variants and panelEventToSse mapper

* test(daemon): add v1 wire-protocol golden fixtures for Critique Theater parser

* feat(daemon): add v1 streaming parser for Critique Theater wire protocol

* chore(contracts): add .js extensions to relative imports for NodeNext consumers

* fix(daemon): satisfy noUncheckedIndexedAccess in v1 parser regex match access

* test(daemon): cover parser failure modes; fix unclosed-PANELIST swallow bug

* fix(daemon,contracts): address PR #387 review

- parser now clamps panelist + DIM scores against the run-declared scale
  captured from <CRITIQUE_RUN scale=...>, not a hardcoded 100
- PANELIST appearing before any <ROUND n=...> opens now throws
  MalformedBlockError rather than emitting events with NaN round
- DIM_RE and MUST_FIX_RE hoisted to module scope and lastIndex reset per
  call so the parser hot path stops recompiling regex per artifact
- overflow check after drain simplified to a plain buf.length > cap test
  (the prior compound condition was always true on the right side and
  obscured intent)
- scoreThreshold <= scoreScale refine gains a 1e-9 epsilon so floating
  slack does not reject semantically valid configs
- round-1 designer ARTIFACT guard gains a comment naming the spec
  invariant and the v2 relaxation path
- 3 new regression tests cover the panelist-without-round, scale=10
  clamp, and scale=20 plumbing cases

* docs(specs): rationale for non-goals, failure-mode rate targets, Phase 10 matrix, Phase 14 doc layout

* Merge branch 'main' into feat/critique-theater

Resolves the contracts/index.ts conflict by keeping the .js extensions added
by chore(contracts) 2d6e8d6 and slotting in the new export for ./api/app-config
introduced upstream by #255 (9d700ec). Critique Theater additions
(./sse/critique, ./critique) preserved in their original positions.

Verified after merge:
  pnpm --filter @open-design/contracts test    -> 10/10 pass
  pnpm --filter @open-design/contracts typecheck -> exit 0
  pnpm --filter @open-design/daemon typecheck  -> exit 0
  pnpm --filter @open-design/web typecheck     -> exit 0

Two daemon tests in tests/media-config.test.ts fail both before and after the
merge because they read real OAuth credentials from the developer machine
instead of using mock fixtures. That's an upstream isolation issue on
origin/main, not something this branch introduces.

* fix: unblock web build and address mrcfps PANELIST oversize bypass

The chore commit that added .js extensions to satisfy daemon's nodenext
typecheck broke apps/web's Next.js build, because webpack tried to resolve
the literal ./common.js when only common.ts exists on disk. Replaced with
a subpath approach: contracts/exports gains a './critique' entry pointing
straight at src/critique.ts (which has no relative imports), and daemon
imports route through @open-design/contracts/critique instead of the
barrel. Web keeps the bundler-friendly barrel; daemon's nodenext walks
only the leaf module. All 13 contracts source files reverted to no-.js.

Separately, mrcfps flagged that parserMaxBlockBytes was only enforced on
the leftover buffer after drain returned, so a complete oversized block
arriving in one chunk slipped past the cap. Added an explicit per-block
size check inside drain for every buffered block type (PANELIST,
ROUND_END, SHIP). Three regression tests yield the whole stream as a
single chunk and assert OversizeBlockError fires before any events emit.

* fix(daemon): close three v1 parser invariant gaps from mrcfps review

Three independent gaps that all let malformed or oversized protocol
output pass the v1 envelope contract:

(1) Envelope guard. ROUND, PANELIST, ROUND_END, and SHIP now throw
MalformedBlockError when state.inRun is false. Without this, a stream
that omits <CRITIQUE_RUN> could still emit panelist_* events without
the run_started handshake, leaving downstream reducers with no run-level
config.

(2) UTF-8 byte length. Both the per-block size check and the post-drain
buf-size check now compare Buffer.byteLength(text, 'utf8') against
parserMaxBlockBytes. The previous string-length comparison let multibyte
content (CJK, emoji) inside <NOTES>/<SUMMARY> exceed the configured
byte cap while staying under the JS string length cap, bypassing the
daemon's resource guard.

(3) Header-end ordering. PANELIST, ROUND_END, and SHIP now require the
opener's > to appear before the matched closing tag. A malformed opener
like <PANELIST role="x" score="8"</PANELIST> previously fell through
to the closing tag's > and emitted events for an invalid block.

Four regression tests cover each gap (ROUND-without-run,
SHIP-without-run, multibyte-byte-cap, malformed-opener).

* feat(daemon): add critique_runs persistence (Task 4.1)

Introduces a new SQLite table critique_runs to back the orchestrator's
run lifecycle. Plan called for ALTER TABLE artifacts ADD COLUMN ..., but
artifacts is not a DB concept in this repo; runs get their own table.

- migrateCritique(db) creates the table + two indexes idempotently and
  is wired into the existing migrate(db) flow on daemon boot.
- CRUD helpers (insertCritiqueRun, getCritiqueRun, updateCritiqueRun,
  listCritiqueRunsByProject, deleteCritiqueRun) round-trip rounds_json
  through helpers so callers see typed CritiqueRunRow.
- reconcileStaleRuns flips stale 'running' rows to 'interrupted' with
  a recoveryReason='daemon_restart' marker, supporting the spec's
  daemon-restart-mid-run failure mode.
- Public CritiqueRunStatus union excludes the in-flight 'running' value
  but the runtime CHECK accepts it, matching the spec's lifecycle.
- 11 vitest cases cover migration idempotence, round-trip, default
  rounds, status validation, update + list ordering, deletion, and
  reconciliation, plus FK CASCADE on project deletion.

* feat(daemon): add Critique Theater transcript writer (Task 4.2)

Streams PanelEvent sequences to .ndjson on disk under the artifact dir,
gzipping to .ndjson.gz when the cumulative UTF-8 byte size crosses
gzipThresholdBytes (default 256 KiB). Uses Node fs streams plus
zlib.createGzip so the writer never holds the full transcript in memory.
readTranscript inverts the path and streams events back, picking the
right pipeline by file extension. Covers happy path, large multibyte,
empty input, mid-stream failure cleanup, and unknown-extension reject.

* feat(daemon): add Critique Theater orchestrator (Task 4.3)

Drives one run end-to-end: parses stdout via parseCritiqueStream, scores
each round through scoreboard helpers, persists lifecycle to critique_runs,
and emits CritiqueSseEvent variants on the existing project event bus.
Honors per-round and total timeouts, applies fallbackPolicy when no
<SHIP> arrives, and tees events into writeTranscript so transcripts
stream to disk without buffering the whole run in memory. Defensive entry
validation throws RangeError on invalid CritiqueConfig before any side
effect.

Also adds scoreboard.ts (computeComposite, decideRound, selectFallbackRound)
and re-exports panelEventToSse/CritiqueSseEvent from the critique subpath
so daemon imports never touch the barrel. Fixes missing .js extensions in
sse/critique.ts that caused NodeNext module resolution errors.

* feat(daemon): wire Critique Theater orchestrator into spawn path (Task 4.4)

Adds loadCritiqueConfigFromEnv to read OD_CRITIQUE_* keys with strict
validation at boot. Branches the existing CLI spawn flow on cfg.enabled:
when false (the M0 default) the legacy single-pass generation runs
unchanged; when true the orchestrator owns the run end-to-end. Same SSE
bus, same artifact dir, no behavior change for users until they flip the
flag.

* fix(lockfile): regenerate to include contracts zod + vitest entries

The earlier conflict resolution took main's lockfile and ran pnpm
install, but the install pass on Windows didn't write the contracts
package's zod and vitest entries back into the lockfile. CI's
--frozen-lockfile install rejected the resulting state. Re-running
pnpm install with --no-frozen-lockfile rewrites the lockfile so it
now matches every package.json across the workspace, including
contracts/zod ^3.23.8 and contracts/vitest ^2.1.8. Verified locally:
pnpm install --frozen-lockfile passes.

* fix(daemon): parser ship envelope, SHIP-before-round guard, real artifactRef (Defects 3 + 5)

- ParserOptions gains projectId + artifactId; the parser threads them into
  every emitted ship event's artifactRef so downstream consumers see the
  real run identity instead of empty placeholders.
- <SHIP> now requires at least one closed <ROUND_END> in the same run;
  malformed streams that emit SHIP before any round complete now throw
  MalformedBlockError instead of bypassing the round-1 artifact invariant.
- The SHIP handler validates the inner <ARTIFACT> block is present and
  non-empty; missing artifact raises MissingArtifactError.
- Three new regressions: SHIP-before-round, SHIP-without-artifact,
  artifactRef populated from parser options.
- Orchestrator threads projectId + artifactId into parserOpts.
- Test fixtures updated to include <ARTIFACT> inside <SHIP> blocks.

* fix(daemon): orchestrator owns lifecycle, gzip atomicity, fallback on timeout (Defects 2,4,7,8)

- Orchestrator now accepts child + childExitPromise, races parser /
  child-exit / abort / timeout in one awaited flow, and SIGTERMs the
  child on every non-clean termination. Server awaits the result so
  the run lifecycle has a single owner.
- ChildExitError surfaces when child exits non-zero mid-stream; the
  run is classified as failed with cause cli_exit_nonzero.
- Timeout / abort with at least one completed round elects a fallback
  via selectFallbackRound and emits a synthetic ship event with
  status=timed_out or interrupted; the score persists to
  critique_runs instead of staying null.
- applyTimeouts includes childExitRace in every Promise.race so early
  child exits are classified without waiting for the total timeout.
  iter.return() cleanup is capped at 200ms to prevent hang on
  stalling generators.
- writeTranscript writes gzip output to transcript.ndjson.gz.tmp,
  fsyncs, then atomic-renames. Crashes mid-write leave no partial
  .gz or .gz.tmp on disk.

* fix(daemon): plain-stream gating, per-run artifact dir, boot reconcile (Defects 1, 2, 6)

- Spawn-path branch now inspects def.streamFormat and only routes through
  runOrchestrator when format === 'plain'. Adapters emitting wrapper
  formats (claude-stream-json, copilot-stream-json, json-event-stream,
  acp-json-rpc, pi-rpc) fall through to legacy single-pass with a
  one-time stderr warning per format. Per-format decoding into the
  orchestrator is reserved for v2.
- critiqueArtifactDir is now path.join(ARTIFACTS_DIR, projectId, runId)
  so concurrent or sequential runs in the same project never overwrite
  each other's transcript or final HTML. Persistence stores the relative
  per-run path.
- reconcileStaleRuns is now invoked after openDatabase on every daemon
  boot with staleAfterMs = critiqueCfg.totalTimeoutMs. Stale running
  rows from a prior crash flip to interrupted with rounds_json.
  recoveryReason='daemon_restart'. Logs a one-line warning naming the
  flipped count when greater than zero.
- Spawn now passes child + childExitPromise to runOrchestrator so the
  orchestrator can race child exit against the parser, abort signal,
  and timeouts in one awaited flow. Server awaits the orchestrator's
  result and surfaces failures through the existing run lifecycle.

* fix(daemon): daemon-authoritative scoring, lifecycle status, stderr ordering, insert type

Round 2 review feedback on PR #481.

1. CritiqueRunInsert.status now accepts 'running' so the boot-reconcile
   tests (and any caller seeding an in-flight row) typecheck without
   casting. The runtime check in insertCritiqueRun already accepted
   'running' against the DB constraint set, only the public type was
   stricter than the DB.
2. round_end keeps the daemon-computed composite authoritative. The
   agent's <ROUND_END composite=...> attribute is advisory: a divergence
   beyond COMPOSITE_TOLERANCE emits a composite_mismatch parser_warning
   so the discrepancy is observable, but the daemon value is what scores
   and persists. Same policy for must_fix.
3. SHIP-handling derives the final status from decideRound(...) using the
   daemon's scored round rather than trusting <SHIP composite=... status=...>.
   A run that the agent claims as shipped but whose daemon composite is
   below threshold now finalizes as below_threshold, so a malformed or
   adversarial stream cannot force a ship.
4. server.ts captures the orchestrator's result and maps the critique
   terminal status to the chat run lifecycle. shipped/below_threshold
   finalize as 'succeeded'; timed_out/interrupted/degraded/failed
   finalize as 'failed'. cancelRequested is honored.
5. stderr forwarding and child.on('error') registrations moved BEFORE
   the orchestrator await so a CLI that floods stderr cannot fill the
   OS pipe and deadlock until the total timeout, and so an early
   child error fired during the run is observed by the same listener
   used after.

Tests:
- tests/critique-authority.test.ts: 3 new regressions (lying ship
  downgraded to below_threshold, mismatch warning emitted, aligned
  composites stay quiet).
- All four affected suites green: 14 orchestrator + 10 spawn-wiring +
  3 boot-reconcile + 3 authority = 30/30.

Workspace typechecks: contracts, daemon, web all exit 0.

* fix(daemon,contracts): inline critique SSE, signal-terminated child, null shipped artifactPath

Round 3 review feedback on PR #481.

1. packages/contracts/src/critique.ts inlines CritiqueSseEvent +
   panelEventToSse + CRITIQUE_SSE_EVENT_NAMES + a local mirror of
   SseTransportEvent. The previous re-export from './sse/critique.js'
   broke the workspace web build (Turbopack cannot rewrite .js to .ts
   on a relative source import) while removing the .js extension broke
   daemon's NodeNext typecheck (it walks this leaf via the './critique'
   subpath export which requires explicit .js extensions). Inlining
   removes the cross-file relative import entirely so both consumers
   walk one self-contained file. packages/contracts/src/sse/critique.ts
   is removed and its co-located test moves up to
   packages/contracts/src/critique.test.ts. The barrel
   packages/contracts/src/index.ts drops the redundant
   './sse/critique' re-export since './critique' already exports the
   same symbols.

2. apps/daemon/src/critique/orchestrator.ts treats a signal-terminated
   child as a terminal race rejection. Previously the race only caught
   non-zero numeric exit codes and treated code === null as
   indefinitely pending, so a SIGTERM from /api/runs/:id/cancel
   resolved childExitPromise as { code: null, signal: 'SIGTERM' } and
   the orchestrator fell through to the no-SHIP fallback path,
   persisting below_threshold instead of interrupted. The race now
   rejects with a new ChildSignaledError when signal !== null, and a
   new catch branch classifies the run as 'interrupted' and (if at
   least one round closed) emits a synthetic ship event with
   status='interrupted' so the persisted row and the SSE transcript
   reflect the actual cause.

3. Same file, ship-handling: artifactPath is now persisted as null on
   shipped runs until a future phase actually extracts the
   <SHIP><ARTIFACT> body to disk. Previously the orchestrator wrote
   ${artifactDir}/${artifactId} even though no file existed at that
   path, so any later replay/export/UI code that trusted
   critique_runs.artifact_path would dereference a missing file. The
   transcript still records the ship event with the artifact reference
   so consumers can find the run.

Tests:
- apps/daemon/tests/critique-lifecycle.test.ts: 2 new regressions
  (SIGTERM-terminated child after one closed round persists
  'interrupted' with a synthetic ship event of the same status; shipped
  run leaves artifactPath null in result and DB row).
- 43 critique-suite tests pass: 14 orchestrator + 11 transcript +
  10 spawn-wiring + 3 boot-reconcile + 3 authority + 2 lifecycle.

Workspace typechecks: contracts, daemon, web all exit 0.

* fix(daemon): buffer raw SHIP, emit only normalized; reject SHIP for unclosed round

Round 4 review feedback on PR #481.

The parser-event loop used to unconditionally collectedEvents.push(event)
and bus.emit(panelEventToSse(event)) for every event, including raw
<SHIP>. SSE clients and the transcript could see the agent's forged
status="shipped" / composite="9.5" before decideRound(...) ran, even
when the daemon later corrected the persisted DB row to below_threshold.
The loop now skips ship events entirely; the orchestrator buffers the
raw shipEvent, runs daemon-authoritative scoring, and emits a single
normalized ship payload built from the daemon's computed composite,
selectFallbackRound's mustFix, and decideRound's status. The transcript
and SSE bus now only ever see the daemon-scored ship.

The unknown-round fallback used to make agent-claimed status/composite
authoritative when SHIP referenced a round that was never closed: a
malformed stream could close low round 1, then send <SHIP round="2"
status="shipped" composite="10">, completedRounds.find(r => r.n === 2)
was undefined, and the orchestrator persisted the agent's value. That
re-opened the scoring-integrity hole the previous round was meant to
close. The orchestrator now drops a SHIP whose round isn't in
completedRounds, emits a parser_warning, and falls through to the
no-SHIP fallback policy. The synthetic ship from selectFallbackRound
gets emitted instead, with daemon-authoritative round/composite/status.

Tests:
- tests/critique-authority.test.ts: extended the lying-ship regression
  to also assert the emitted critique.ship payload is downgraded
  (status='below_threshold', composite < threshold), so the SSE bus
  cannot see the agent's claim. Added a new regression where SHIP
  references an unclosed round 2: the agent ship is dropped, a
  parser_warning fires, the fallback selects round 1, and the only
  emitted critique.ship has round=1 and status=below_threshold.
- 44 critique-suite tests pass: 14 orchestrator + 11 transcript + 10
  spawn-wiring + 3 boot-reconcile + 4 authority + 2 lifecycle.

Workspace daemon typecheck exits 0.

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-05 15:50:35 +08:00
PerishFireandGitHub 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
Chris TamandGitHub a3a0ae6ced fix(daemon): respect baseUrl path verbatim in OpenAI-compat proxy (#410)
* fix(daemon): respect baseUrl path verbatim in OpenAI-compat proxy

`appendVersionedApiPath` previously force-injected `/v1` unless the
supplied baseUrl ended with `/vN`. That broke any provider whose
OpenAI-compatible surface lives under a sub-path:

  https://api.deepinfra.com/v1/openai → ".../v1/openai/v1/chat/completions"
  https://openrouter.ai/api/v1        → ".../api/v1/chat/completions" (worked, by luck of /vN suffix)

Now the auto-`/v1` only fires when the user supplied no path at all, so
DeepInfra, OpenRouter, and any other sub-path-mounted compat surface
route to the right endpoint while the canonical
`https://api.openai.com` / `https://api.anthropic.com` shortcuts still
work. Adds a regression test table covering the matrix.

* Account for Anthropic style URLs
2026-05-05 13:22:54 +08:00
GoemonandGitHub b5598572d8 fix(daemon): scope -32603 recovery to session/set_model request id (#492)
Address review feedback from lefarcen and mrcfps on PR #492:

P1: Track setModelRequestId to scope the recovery block to the exact
session/set_model request. This prevents duplicate prompt sends if
session/prompt returns -32603 (which would otherwise match on
expectedId + non-default model conditions).

P1: Add promptRequestId === null guard so the recovery path only
triggers before a prompt has been sent.

P2: In detectAcpModels, only suppress -32603 on unexpected ids.
Expected-id -32603 errors (initialize, session/new) are real probe
failures and should reject immediately rather than causing a silent
15s timeout.

P2: In attachAcpSession, expected-id -32603 errors that don't match
setModelRequestId now call fail() instead of falling through. This
prevents initialize/session/new/session/prompt failures from being
silently swallowed.
2026-05-05 13:03:13 +08:00
0ab98fed07 fix(daemon): correct Claude Code --add-dir capability detection (fix #430) (#440)
Probe 'claude -p --help' (subcommand help) instead of 'claude --help'
(global help), because --add-dir and --include-partial-messages are
subcommand-specific flags that do not appear in the global help output.

Previously, successful capability probing would set caps.addDir = false
because the flag was not found in global help, causing the daemon to
never pass --add-dir to spawned Claude Code processes. This broke
absolute-path reads for skill seeds and design-system specs, as
Claude Code's sandbox policy blocks access to paths outside the
project cwd unless explicitly allowed via --add-dir.

The probing-failure / timeout path already worked correctly (empty
caps object defaults to passing --add-dir). The failure manifested
only when help probing succeeded and explicitly failed to locate
the subcommand flags.

Co-authored-by: bojiehuang <bojiehuang@bojiehuangdeMacBook-Pro.local>
2026-05-05 12:52:31 +08:00
Justin GaoandGitHub cc8add4f09 feat(daemon): add link code folder support for agent context (#455)
* feat(daemon): add link code folder support for agent context

Users can now link local code directories to a project so the AI agent
reads their source code via --add-dir when generating designs. The
import menu's "Link code folder" item opens a native OS folder picker,
and linked folders appear as removable chips below the chat input.

- Add linkedDirs field to ProjectMetadata contract
- Add POST /api/dialog/open-folder endpoint (osascript/zenity/PowerShell)
- Add validateLinkedDirs with path safety checks (absolute, exists, blocklist)
- Append linked dirs to extraAllowedDirs in startChatRun
- Add system prompt hint listing linked code folders
- Render linked folder chips in ChatComposer with add/remove
- Add i18n strings for all 16 locales
- Add 8 unit tests for validateLinkedDirs

* fix: address PR review feedback

- Add JSDoc type annotations to validateLinkedDirs for strict mode
- Check path.isAbsolute before resolve to catch relative inputs
- Allow linking when projectMetadata is undefined (default to prototype)
- Remove redundant PATCH in ProjectView callback

* fix: use inline TS annotations instead of JSDoc in linked-dirs.ts

* fix(daemon): harden linked-dirs validation against security bypasses

- Resolve symlinks with realpathSync.native before checking blocklist
- Reject filesystem root (/) and drive roots as linked dirs
- Canonicalize blocklist entries to handle macOS /etc -> /private/etc
- Validate linkedDirs on project creation, not just PATCH
- Re-validate persisted linkedDirs in startChatRun before use
- Add tests for root, symlink-to-blocked-dir, and realpath resolution

* fix: narrow union type before accessing .error in tests
2026-05-05 12:46:39 +08:00
PerishFireandGitHub 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
aitonicandGitHub e5ed4bbc76 fix(packaged): preserve *_API_KEY env vars for CLI agents (#404)
* Forward API key env vars to packaged sidecars

## Summary

Forward provider credential environment variables from the packaged desktop app process to packaged sidecar children.

## Why

The packaged app currently forwards only a small fixed environment allowlist to sidecars:

- HOME
- LANG
- LC_ALL
- LOGNAME
- TMPDIR
- USER

This prevents CLI agents launched by the packaged daemon from seeing provider credentials such as `CODEX_API_KEY`, even when the desktop app itself was launched with that variable set.

## Fix

This keeps the existing allowlist and additionally forwards environment variables ending in:

- `_API_KEY`
- `_TOKEN`

I intentionally did not change PATH or Bun-related behavior in this PR, since CLI discovery already works. The issue here is specifically credential forwarding.

## Verification

I verified the root cause locally by patching the packaged output:

`@open-design/packaged/dist/index.mjs`

After forwarding `CODEX_API_KEY`, the packaged desktop app was able to launch Codex successfully.

## Testing

I did not run the full project build locally. I verified the behavior by patching the installed macOS app output and confirming that forwarding `CODEX_API_KEY` fixes the Codex launch failure.

* Add includeProviderSecrets parameter to env functions

Updated. Provider-secret forwarding is now daemon-only: *_API_KEY / *_TOKEN are included only when spawning the daemon sidecar, while the web/Next sidecar stays on the original minimal allowlist.
2026-05-05 10:21:53 +08:00
9979afa3ae fix: cap htmlPreviewSlideState Map to prevent unbounded memory growth (#488)
The module-level Map cached slide state per project+file key but never
evicted entries. In long-lived sessions with many projects, this grows
without bound. Add a 64-entry cap with FIFO eviction.

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:24:27 +08:00
Ajay SatishandGitHub ec7dafc007 feat(daemon): add Kilo CLI (ACP) (#480)
* docs(readme): refresh contributors wall

* docs(readme): refresh contributors wall

* feat: kilo cli

* fix: use default model option for kilo

* chore: add  agent_diff id unique test

* chore: add deepseek to docs
2026-05-05 09:17:56 +08:00
IrixandGitHub 6380c48a48 Update Codex sandbox invocation (#477) 2026-05-05 09:15:32 +08:00
ChildhoodAndyandGitHub de62baa0e8 fix(web): aspect ratio cards no longer overflow into siblings (#476)
The 380px sidebar gives the 3-column aspect grid only ~92px per cell. The
flex-row card layout reserved 34px for the glyph plus 7px gap, leaving
~31px for the label — but "Landscape"/"Portrait" at 13px / weight 650 are
~75px wide. Without `min-width: 0` on the flex/grid items, the strong
text refused to shrink and overflowed, so each card's label visually
covered the next card's glyph.

Switch the aspect cards to a column layout (glyph above, label + ratio
below) so the label has the full card width. Add the missing
`min-width: 0` on `.newproj-option-card` and `.aspect-copy` as a defense
against future long-text regressions, and rebalance glyph dimensions and
font sizes to match the tighter cards.

Closes #242
2026-05-05 09:12:07 +08:00
jiakebogeandGitHub 2473ab9567 fix(web): add copy buttons for FileViewer code blocks (#471)
* fix(web): add copy buttons for FileViewer code blocks

* fix(web): harden FileViewer markdown copy controls

* fix(web): restore focus after clipboard fallback

* test(e2e): restore execCommand after markdown copy tests
2026-05-05 09:09:39 +08:00
ChildhoodAndyandGitHub a4fb90a9f3 fix(design-files): clear selection on project switch (#465)
* fix(design-files): clear selection on project switch to prevent cross-project leak

When switching from project A to project B, filenames present in both
projects (e.g. index.html) would survive the intersection-prune and
remain selected, causing handleBatchDownload() to post against the
wrong projectId. Now selection is fully cleared on projectId change.

* fix(design-files): use key-based remount to prevent cross-project selection leak

Replace the useEffect projectId clearing with a key={projectId} on
DesignFilesPanel. useEffect runs after render, leaving a window where
stale selections from project A could be used against project B. The
key tells React to unmount and remount the component synchronously on
project switch — no async gap.
2026-05-05 09:06:11 +08:00
alburtandGitHub b4e69ac61b fix(copilot): copilot prompt processing with correct command format (#466) 2026-05-05 00:28:42 +08:00
ChildhoodAndyandGitHub bc04d61903 feat(design-files): add batch ZIP download with multi-select (#405)
* feat(design-files): add batch ZIP download with multi-select

Add checkbox-based multi-select to the Design Files panel and a new
POST /api/projects/:id/archive/batch endpoint that zips selected files
using the project name as the archive filename.

* fix(i18n): add missing batch-download keys to uk locale

The upstream main branch has a uk.ts locale that didn't exist in the
fork. Without these keys the web typecheck fails against the full
locale set.

* fix(design-files): harden batch archive and prune stale selections

- Use lstat to reject symlinks, dotfiles, and .artifact.json sidecars
  in buildBatchArchive (mirror listFiles/collectFiles allowlist)
- Reject invalid names explicitly instead of silently skipping
- Prune stale filenames from selected set on files/projectId change

* fix(daemon): tighten batch archive allowlist with segment-level checks

- Check every path segment for hidden directories, not just basename
- Walk intermediate directories with lstat to reject symlinks at any level
- Fail-fast on any ineligible file instead of silently skipping
2026-05-05 00:10:26 +08:00
Tom HuangandGitHub da2b007a43 feat(daemon): add DeepSeek TUI as a code agent adapter (#439)
* feat(daemon): add DeepSeek TUI as a code agent adapter

Register `deepseek` (with `deepseek-tui` cargo-only fallback) in
AGENT_DEFS via `deepseek exec --auto [--model X] <prompt>` and plain-text
streaming. Ships `deepseek-v4-pro` / `deepseek-v4-flash` as fallback
model hints; users can paste any other id (incl. NIM / Fireworks /
SGLang routes) via the custom-model input.

Web UI gets a DeepSeek-blue gradient icon, label/alias mapping, and
docs/agent-adapters.md §5.9 documents the auth state, prompt-as-argv
Windows size limit, and the upstream gap that prevents stdin delivery
today (clap declares `prompt: String` as a required positional).

Adds .deepseek/ to .gitignore alongside the other per-agent runtime
data dirs so first-launch trust files don't leak into git.

* fix(daemon): drop unsupported deepseek-tui fallback bin

The `deepseek` dispatcher owns `exec` / `--auto`; `deepseek-tui` is the
runtime companion it invokes. Listing `deepseek-tui` in fallbackBins
advertised availability for a host that only had the TUI binary, but
buildArgs still emitted `<resolved> exec --auto <prompt>` — which
deepseek-tui itself doesn't accept, so the first /api/chat run would
fail. Upstream documents both binaries as required (npm and cargo paths
install them together), so the fallback didn't correspond to a supported
install. Pin the absence in the agents test and update docs §5.9 + the
adapter table to match.

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

* fix(daemon): pre-flight DeepSeek TUI prompts against argv byte budget

DeepSeek's exec mode requires the prompt as a positional argv arg (no
`-` stdin sentinel upstream), so a fully composed OD prompt — system
text + history + skills + design-system content + the user message —
can blow Windows' ~32 KB CreateProcess limit (or Linux MAX_ARG_STRLEN
on extreme edges) and surface as a generic spawn failure instead of
a DeepSeek-specific, user-actionable message. The adapter now declares
`maxPromptArgBytes = 30_000` (leaves ~2.7 KB argv headroom for `exec
--auto --model <id>` and Windows quoting), and the /api/chat spawn
path checks the composed prompt against that budget before calling
`spawn`. Oversized prompts fail fast with `AGENT_PROMPT_TOO_LARGE`
and guidance to reduce skills/design context or pick an adapter with
stdin support.

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

* test(daemon): pin DeepSeek argv-budget guard with regression tests

The previous spawn-path guard inlined the byte-budget check in the
chat handler, so the only safety net for the DeepSeek argv-only
prompt-delivery shape was a static "the field exists" assertion —
nothing actually exercised the AGENT_PROMPT_TOO_LARGE path or the
short-prompt happy path. Extract the check into a pure
`checkPromptArgvBudget(def, composed)` helper in agents.ts, call it
from /api/chat before bin resolution (so the guard is order-
independent and fires regardless of whether the adapter binary is
on PATH in CI), and add a regression test that exercises both the
oversized-prompt branch (over the conservative under-Windows-
CreateProcess budget) and the short-prompt branch, plus a UTF-8
byte-vs-codepoint case and a stdin-adapter no-op case so the guard
can't silently regress or leak onto adapters that ship the prompt
over stdin.

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

* fix(daemon): pre-flight DeepSeek prompts against Windows .cmd-shim quoting

The first-pass argv-byte guard only inspects the raw composed prompt, so
on Windows an npm-installed `deepseek` resolves to a `.cmd` shim and the
spawn path then wraps the call in `cmd.exe /d /s /c "<inner>"` with
every embedded `"` doubled by `quoteWindowsCommandArg`. A quote-heavy
prompt (code blocks, JSON-shaped skill seeds) under the 30,000-byte
budget can therefore still expand past CreateProcess's 32_767-char
`lpCommandLine` cap and surface as a generic spawn ENAMETOOLONG instead
of the DeepSeek-named, actionable `AGENT_PROMPT_TOO_LARGE` the budget
guard was meant to provide. Add a second pure helper
`checkWindowsCmdShimCommandLineBudget(def, resolvedBin, args)` that
mirrors the platform layer's per-arg quoting and recomputes the would-be
command line length whenever the resolved binary is a `.cmd` / `.bat`
shim, and call it from `/api/chat` after `buildArgs` / `resolveAgentBin`
so the same SSE error fires before `spawn`. Pin the new path with a
quote-heavy regression (prompt is under the byte budget but doubles
past the kernel cap) plus no-op tests for non-`.cmd` resolutions, null
bin, and stdin-only adapters so the guard can't drift back.

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

* fix(daemon): extend DeepSeek argv guard to direct .exe Windows installs

The cmd-shim guard added in 9011361 early-returned for non-`.cmd` /
`.bat` resolutions, so a Windows host that resolved `deepseek` directly
to a `.exe` (cargo-installed CLI, hand-built release, anything outside
the npm shim path) bypassed the post-`buildArgs` budget check entirely.
Direct `.exe` spawns skip the `cmd.exe /d /s /c "<inner>"` wrap, but
Node/libuv still composes a CreateProcess `lpCommandLine` by walking
each argv element through `quote_cmd_arg` — every embedded `"` becomes
`\"`, backslashes adjacent to a quote get doubled. A quote-heavy
prompt (code blocks, JSON-shaped skill seeds) under the 30,000-byte
`maxPromptArgBytes` budget can therefore still expand past the kernel's
32_767-char cap on those installs and surface as a generic spawn
ENAMETOOLONG instead of the actionable `AGENT_PROMPT_TOO_LARGE` the
guard was meant to provide.

Add a sibling pure helper `checkWindowsDirectExeCommandLineBudget(def,
resolvedBin, args)` that mirrors libuv's quoting math (empty -> `""`;
no whitespace/quote -> pass-through; quote-only -> simple wrap;
otherwise per-char escape with backslash-doubling around quotes and
trailing backslashes) and recomputes the would-be command line length
whenever the resolved binary is a non-shim Windows install. The two
Windows guards are mutually exclusive: the cmd-shim guard owns
`.bat` / `.cmd`, the direct-exe guard owns everything else, so a
single oversized prompt never double-emits an SSE error. POSIX paths
on POSIX hosts still skip both guards (no CreateProcess in play), and
stdin-delivered adapters still skip the entire post-buildArgs path.

Wire the new helper into `/api/chat` after the cmd-shim check, pin
the path with a quote-heavy regression on a `C:\Program
Files\DeepSeek\deepseek.exe` resolved bin (prompt under the byte
budget but past the kernel cap once libuv-quoted), plus no-op tests
for ordinary prompts, `.cmd`/`.bat` resolutions, POSIX paths,
null/empty bin, and stdin-only adapters, plus a mutual-exclusivity
assertion so the two guards' division of labour can't drift.

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

* fix(daemon): neutralize cmd.exe %var% expansion in DeepSeek argv

Wrap each `%` in `"^%"` inside `quoteWindowsCommandArg` so cmd.exe's
percent-expansion can't substitute env values into a `.cmd`-shim spawn
when the DeepSeek argv carries a prompt mentioning `%DEEPSEEK_API_KEY%`.
Mirror the change in the agents.ts helper used by the budget guard, add
`AGENT_PROMPT_TOO_LARGE` to the contracts so typed SSE consumers
recognize the existing daemon error code, and pin the literal-prompt
contract with regression tests.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
2026-05-05 00:08:26 +08:00
nmsnandGitHub 59e1e01233 fix(web): prevent vertical scrollbar on artifact preview frame (#453)
* fix(web): prevent vertical scrollbar on artifact preview frame

The preview uses transform: scale() for zoom, which doesn't affect
layout size. When previewScale < 1, the scale wrapper's layout size
(100/scale%) overflows the parent, triggering overflow: auto on
.viewer-body. Add overflow: hidden on .comment-preview-layer to
clip the layout overflow before it propagates.

* fix(web): use iframe-only clip wrapper instead of layer-level overflow

Move overflow: hidden from .comment-preview-layer to a dedicated
.comment-frame-clip wrapper that only wraps the iframe. This prevents
the layout overflow from propagating to .viewer-body while keeping
comment overlays and popovers unclipped.
2026-05-04 22:57:20 +08:00
emilneanderandGitHub 33c3b94b42 feat(daemon): add od mcp - expose Open Design as an MCP server (#399)
* feat(daemon): add `od mcp` subcommand for stdio MCP server

Lets a coding agent in a different repo (Claude Code, Cursor, Zed)
pull files from a locally-running OD project over the Model Context
Protocol — no export/import zip dance.

The MCP server is a thin stdio process that proxies read-only tool
calls to the daemon's existing HTTP API; no daemon-side changes
required. Exposes 8 tools:

  list_projects, get_project,
  list_files, get_file,
  list_skills, get_skill,
  list_design_systems, get_design_system

Wired exactly like `od media`: a hoisted flag set, a SUBCOMMAND_MAP
entry, a thin handler that resolves OD_DAEMON_URL and hands off to
src/mcp.ts. Tool dispatch is a switch over the tool name; each branch
fetches the matching daemon route and surfaces the response as MCP
text content. Binary mimes return a clear error pending phase-2
support.

Lifecycle gotcha worth flagging: Server.connect(transport) only
*starts* the stdio reader; the promise resolves immediately. Without
holding the function awaiting until transport/stdin close, cli.ts's
top-level process.exit(0) kills the server before the first request
arrives. The fix in src/mcp.ts holds until onclose / stdin EOF.

Wire-up example for a consuming repo:

    {
      "mcpServers": {
        "open-design": {
          "command": "od",
          "args": ["mcp"],
          "env": { "OD_DAEMON_URL": "http://127.0.0.1:7456" }
        }
      }
    }

New dep: @modelcontextprotocol/sdk (MIT, official Anthropic SDK).

* feat(daemon): add MCP server instructions for zero-shot LLM context

Hand the consuming LLM a system-prompt-style overview of the OD
workflow so it picks the right tool without prompt-engineering on
the user's side. Mentions get_artifact and project-name resolution
ahead of their actual implementation; both ship in the same batch.

* feat(daemon): resolve MCP project args by UUID, name, or substring

Lets a consuming agent say `project: "recaptr"` instead of pasting a
UUID. Match order: exact id → exact name (case-insensitive) →
slug-normalized name (strips trailing " (N)", normalizes whitespace) →
substring (errors if multiple). UUID inputs short-circuit and never
hit the daemon.

* feat(daemon): surface entryFile and kind on MCP get_project response

Promote metadata.entryFile and metadata.kind to top-level fields so
consumers (including get_artifact in this branch) can find the entry
without digging through nested metadata blobs.

* feat(daemon): add MCP get_artifact tool for bundle retrieval

A design rarely lives in a single file. get_artifact pulls the entry
HTML/JSX plus every sibling it references (tokens CSS, JSX modules,
imported components) in one call, so a consuming agent doesn't need
to parse HTML and round-trip per file.

Three modes:
  auto (default): BFS over relative <script src>, <link href>,
    <img src>, <source/video src>, JSX import/from, CSS url(), with
    depth cap 3 and a visited set. CDN, data:, mailto:, anchors, and
    paths containing .. are skipped.
  all:    every textual file in the project (mirror of /archive
          minus binaries).
  shallow: just the entry file (same as get_file).

Output is a structured JSON blob with name/mime/size/content per
file and the project's manifest metadata at the top.

* feat(daemon): add /api/projects/:id/search route + MCP search_files

Server-side substring search across textual project files. Returns
file, 1-indexed line, and snippet, capped at 1000 matches. Exposed
through the MCP layer as search_files(project, query, pattern?, max?).

Treats the query as a literal substring (regex chars escaped) to
avoid catastrophic-backtracking attacks from LLM-supplied input.
Honors the project dir's existing path-safety guards via listFiles.

* feat(daemon): add since= filter to /files route + MCP list_files arg

Lets a consumer poll for "what's changed since I last looked" without
re-walking every file. Daemon-side: parse since= as ms, filter
listFiles output by mtime. MCP-side: forward as URL query.

* feat(daemon): expose skills and design systems as MCP resources

Catalog reads are stable reference material — they fit MCP's
resources surface (LLM-passive) better than tools (LLM-active).
Skills and design systems each become resources at
od://skills/<id>/SKILL.md and od://design-systems/<id>/DESIGN.md;
existing list_skills / get_skill / list_design_systems /
get_design_system tools remain as fallbacks for clients that don't
handle resources cleanly.

* fix(daemon): tighten MCP correctness in get_artifact and resources

Several silent-failure paths and minor footguns the first pass missed:

  - get_artifact auto: the entry's own fetch now raises a clear
    error instead of returning files: []. Previously a typo in
    `entry:` looked like an empty project.
  - get_artifact: invalid `include` value returns a clear error
    listing the valid modes instead of silently behaving as auto.
  - get_artifact all: includes binary files as metadata stubs to
    match auto's behavior. Both modes are now strict supersets of
    shallow.
  - extractRelativeRefs: gate JS-only patterns (import/from/require/
    dynamic-import) by file mime/extension so prose in markdown or
    HTML doesn't generate spurious 404 round-trips on words like
    "imported from 'X'".
  - extractRelativeRefs: cover <iframe>, <audio>, srcset, and
    CSS @import — common in real OD output.
  - resources/list descriptions are collapsed to a single line
    (newlines + repeated whitespace -> one space) so MCP UIs that
    don't normalize whitespace render cleanly.
  - fetchProjectFile: 0-byte binary files no longer report size: null
    due to falsy short-circuit on Number(content-length).

* perf(daemon): cache MCP project list for 5s in resolveProjectId

A typical agent session calls list_files/get_file/get_artifact several
times in a row, each with a project name argument. Each previously
re-fetched /api/projects. Cache the list in module scope with a 5s
TTL so back-to-back lookups are local; renames in the OD UI still
propagate within a few seconds.

* feat(daemon): MCP UX polish — tool order, annotations, get_artifact maxBytes

Three changes well-behaved MCP clients pick up automatically:

  - Tool ordering. list_projects + get_artifact are now first; LLMs
    that weight earlier entries surface the bundle path before
    per-file fetching. Catalog tools (list_skills, get_skill,
    list_design_systems, get_design_system) sit at the bottom; they
    are also exposed as MCP resources.
  - readOnlyHint / idempotentHint / openWorldHint annotations on
    every tool so clients can skip confirmation prompts on safe
    tools and let the LLM know re-running is fine. Per-tool `title`
    annotations give clients a friendlier display name than the
    snake_case tool id.
  - get_artifact gains a `maxBytes` arg (default 1.5MB). Once the
    accumulated textual content crosses the cap, remaining files
    are dropped and `truncated: true` is set on the bundle so the
    consumer knows to use list_files / get_file for the rest.

* feat(daemon): expose user's active OD project/file via MCP

The "what file are you on?" round-trip the agent had to do every
session is now answered automatically. Three pieces:

  - Daemon: in-memory active-context slot with 5-minute TTL.
    POST /api/active sets {projectId, fileName}; GET /api/active
    returns the current value enriched with projectName, or
    {active:false} when the slot is empty/stale. Cleared on
    daemon restart.
  - Web: a small useEffect in App.tsx posts the active project +
    file to the daemon on every route change. Best-effort fire-
    and-forget; a missing daemon doesn't surface an error.
  - MCP: get_active_context tool (no args) and a matching MCP
    resource at od://focus/active. The tool is listed second,
    right after list_projects, so an LLM picks it up before
    asking for ids. Server instructions tell the model to call
    it FIRST when the user says "this file" / "the design I have
    open" / "what I'm looking at."

End to end: user opens a project in OD, agent in another repo
calls get_active_context() → gets {projectName: "recaptr",
fileName: "recaptr-onboarding-4.html"}, then immediately calls
get_artifact(project: "recaptr") with no further user input.

* feat(daemon): make MCP project arg optional, fall back to active OD context

get_artifact, get_project, get_file, search_files, and list_files now
accept project as optional. When omitted, the MCP resolves project
from /api/active so an agent in another repo can call

  search_files({ query: "Polaroid" })

without first asking the user "which project?". get_file and
get_artifact also default their path/entry to the active file, so
get_file({}) returns whatever the user is currently looking at.

The implicit path stamps `usedActiveContext` on JSON responses (or a
separate `[od:active-context …]` content block on get_file) so the
agent can see exactly which project/file got chosen. Explicit
project args pass through with zero added overhead.

Cuts the common case from two MCP round trips
(get_active_context → search_files) to one. Server instructions and
get_active_context's own description are updated to point at the
new default.

* fix(daemon): require same-origin for /api/active POST and GET

The active-context endpoint was added without isLocalSameOrigin
guard. Since the daemon binds 0.0.0.0 by default, a LAN peer could
GET it to learn what file the user has open, or POST it to redirect
the MCP fallback to a project of their choice. Same-origin only is
the right scope: the web app proxies its requests through Next.js
on the daemon port, and the MCP runs over loopback in-process, so
both legitimate callers pass.

Pattern matches the existing /api/app-config etc. guards.

* feat(daemon): add /api/mcp/install-info for cross-platform install snippets

The Settings -> MCP server panel needs absolute paths to node and
the daemon's built cli.js so it can render snippets that work on a
fresh source clone (where `od` is not on PATH) and dodge the
/usr/bin/od octal-dump tool that ships on macOS/Linux and would
otherwise shadow ours.

Endpoint returns:
  - command: process.execPath (the node binary running the daemon)
  - args: [<absolute path to dist/cli.js>, "mcp"]
  - daemonUrl: http://127.0.0.1:<port>
  - platform: process.platform (so the panel can localize ~/.cursor
    vs %USERPROFILE%\.cursor and Cmd vs Ctrl shortcuts)
  - cliExists / nodeExists: existsSync checks on both binaries
  - buildHint: human-readable build/reinstall instructions when
    either path is missing

isLocalSameOrigin guard same as /api/active. Cached for 5s because
the panel may re-fetch on every open and the paths cannot change
without a daemon restart.

Test file covers the happy path, cross-origin rejection, two
allowed-Origin variants, and the cache by counting fresh resolves
across rapid calls. 5/5 pass.

* refactor(daemon): tighten MCP surface, trim descriptions, polish copy

Three intertwined cleanups that all live in mcp.ts + cli.ts:

1. Drop catalog tools from MCP. list_skills / get_skill /
   list_design_systems / get_design_system are removed. The audience
   is a coding agent in a separate repo consuming Open Design's
   output; it cannot run skills (those are recipes Open Design uses
   to generate) and design-system DESIGN.md is reference material
   that already ships as an MCP resource. Keeping the catalog as
   tools cost ~350 token-overhead per turn for capabilities the
   agent could not act on. Tool count: 11 -> 7.

2. Trim tool descriptions. The active-context fallback explanation
   was repeated in 5 separate tool descriptions; hoisted into
   PROJECT_ARG and explained once in the server `instructions`
   block instead. Saves ~150-200 tokens per tools/list response.

3. User-facing branding pass. Tool titles, tool descriptions,
   resource names, error messages, comments, and `od mcp --help`
   now consistently use "Open Design" rather than "OD". Internal
   abbreviation `OD` is retained only inside the server
   instructions block where it is introduced inline as "Open Design
   (OD)" for compactness across multi-paragraph guidance.

Em dashes replaced with hyphens throughout, per project style.

* feat(web): add MCP server install panel in Settings

New "MCP server" section in the Settings dialog, surfacing
copy-paste install snippets for the major MCP-compatible coding
agents (Claude Code, Cursor, VS Code, Antigravity, Zed, Windsurf).

Highlights:
  - In-brand custom dropdown (reuses the existing .ds-picker
    pattern from the design-system / prompt-template pickers, click
    outside / Escape to close, chevron animates) instead of a
    native <select>.
  - Per-client snippet that uses absolute paths to node + cli.js
    fetched from /api/mcp/install-info on mount, so it works even
    when `od` is not on PATH.
  - Cursor gets a one-click "Install in Cursor" deeplink
    (cursor://anysphere.cursor-deeplink/mcp/install) that pops an
    approval dialog and writes the config for the user. UTF-8-safe
    base64 so paths with accented characters do not throw.
  - Per-OS path hints (~/.cursor on POSIX, %USERPROFILE%\.cursor
    on Windows) and keyboard shortcuts (Cmd vs Ctrl).
  - Build-required warning card when cli.js or the node binary
    does not exist on disk; deeplink button disables in that state.
  - Prominent "restart your client to pick up the new server"
    callout below the snippet, with per-client guidance.
  - Capability list ("what your agent can do") instead of a tool-
    name dump, so non-developer designers can also tell what is
    possible without reading MCP docs.

README adds a short "Use Open Design from your coding agent"
section that points at the panel and summarizes the per-client
flow (one-click for Cursor, JSON merge elsewhere). Read-only by
design; the daemon must be running locally.

* docs(readme): align MCP server section with the Settings panel

The "Use Open Design from your coding agent" section had drifted
from what the panel actually emits and lists.

- Add Antigravity to the supported-client list (previously missing).
- Drop the "(GitHub Copilot)" parenthetical from VS Code so the
  label matches the panel.
- Fix the Claude Code line: we no longer emit a single
  `claude mcp add ...` shell command. The snippet is JSON; the
  panel additionally suggests `claude mcp add-json` as the safer
  way to apply it instead of hand-editing ~/.claude.json.
- Swap the "find the Polaroid section" example for two more
  universal phrases ("build this in my app", "match these
  styles") that match what the panel surfaces.
- Add a one-line "restart or reload your client after install"
  note - this was prominent in the panel and absent from the
  README.
- Trim the /usr/bin/od octal-dump aside; it was technical detail
  that did not earn its space at the README intro level.

* feat(web): add Codex CLI to the MCP server install panel

Codex is a first-class supported coding agent (listed alongside
Claude Code, Cursor, etc. in the README's PATH-detected agent
table) but the install panel was missing it.

Codex stores MCP server config at ~/.codex/config.toml (TOML, not
JSON) under an `[mcp_servers.<name>]` table, and the same file is
shared between the Codex CLI and the Codex IDE extension - so one
install covers both. Added a 7th client entry that emits the right
TOML snippet, expanded the snippet-lang union to include 'toml'
(behaves like 'json' for whitespace handling, just a different
syntax-highlight hint).

For our minimal payload (just command + args), JSON.stringify
happens to produce valid TOML literal values since TOML basic
strings use the same double-quote escape rules as JSON, and TOML
inline arrays match JSON array syntax. No new TOML serializer
needed.

README updated to list Codex among the supported clients.

Schema verified against https://developers.openai.com/codex/mcp.

* fix(daemon): accept any loopback origin in same-origin guard

The previous port-pinned check required the request's Origin to match
either the daemon's own port or OD_WEB_PORT. tools-dev does not pass
OD_WEB_PORT to the daemon process, so any browser POST to /api/active
proxied through the dev web (port 17573 etc.) was rejected with 403,
and get_active_context always returned {active: false}.

Relax to a loopback-prefix match: any http://127.0.0.1:*,
http://localhost:*, or http://[::1]:* origin passes regardless of
port. Cross-origin (https://evil.com) is still rejected. The
trade-off is that another local web app on a different loopback port
could now CSRF the daemon; same-origin checks are inherently a CSRF
defense, not a network ACL.

* fix(web): make Claude Code MCP snippet a real copyable one-liner

claude mcp add-json open-design '<json>' takes only the inner
server-config object, not the full {"mcpServers": ...} wrapper, and
rejected the wrapped shape with "Invalid configuration: : Invalid
input". Pass only the inner config, and inline the JSON into the
command itself so the snippet is a real one-liner the user can copy
and paste, no template substitution.

* test(daemon): drop loopback-prefix assertions superseded by upstream origin policy

The two proxy-flow allow tests were added in ae13094 to cover our
relaxed isLocalSameOrigin. Main's port-pinned implementation (from
#365) now handles the dev-flow via the web sidecar proxy origin
rewrite (#a719f02), making the relaxation -- and these tests --
unnecessary.

Also replace the inline LOOPBACK_*_RE / isLocalSameOrigin replica in
mcp-install-info.test.ts with a direct import from server.ts so both
test files stay in sync with the production guard automatically.

* fix(daemon): bake daemon URL into MCP install-info args

The install panel snippet previously emitted `od mcp` with no daemon
URL, so the MCP server always fell back to the hardcoded default port
7456. When tools-dev starts the daemon on a non-default port the
snippet silently targets the wrong daemon.

Fix: include --daemon-url http://127.0.0.1:<port> as the third arg so
the generated snippet is always tied to the running daemon's actual
port. Update the matching mini-app and assertion in the install-info
test.

* fix(daemon): address MCP reviewer feedback

- extractRelativeRefs: replace blanket `includes('..')` rejection with
  proper POSIX-style path normalization. `../tokens.css` in a nested
  project layout now resolves to `tokens.css` instead of being
  silently dropped.

- getArtifact: add MAX_FILES=200 cap to BFS auto and include=all modes.
  Pass `remainingBytes` to fetchProjectFile so it can bail early when
  the server-advertised content-length would already exceed the budget.

- resolveProjectId: return {id, name, source} instead of a bare id.
  Callers echo `resolvedProject` in the response when the match was by
  slug or substring, letting the agent confirm which project was
  chosen without an extra round-trip.

- getFile: thread `resolved` through so substring matches surface
  the same `[od:resolved-project ...]` annotation.

- @ts-nocheck: add a comment explaining the Zod-vs-JSON-Schema SDK
  mismatch so future contributors don't remove it accidentally.

- get_active_context description: note the ~5-minute cache TTL.

* test(daemon): restore @ts-nocheck on mcp-install-info test

Dropped accidentally when replacing the import header. The directive
suppresses expected test-file noise (baseUrl pre-assignment and
res.json() unknown return type); keeping it avoids littering the test
body with `as any` casts for zero real safety benefit.

* docs(readme): expand MCP section with why-MCP, security model, and recovery note

- Soften "No zip export, no copy-paste" to "Replaces the
  export-then-attach loop" per reviewer feedback.
- Add "Why MCP?" paragraph explaining the structured-API benefit over
  zip exports.
- Add daemon-not-running recovery note (clear error, not a crash;
  start with pnpm tools-dev and retry).
- Add security model callout: read-only, loopback-only, Host/Origin
  guard rejects non-loopback requests.

* docs: complete security model and daemon recovery notes for MCP section

8.3: Expand README security model to include stdio child process context,
trust framing (treat like a VS Code extension), and OD_BIND_HOST opt-in
for LAN exposure.

8.4: Replace terse "daemon not running" note in README with a full
recovery sentence covering the start-agent-before-Open-Design case.
Add the same recovery note as a footer paragraph in IntegrationsSection
so users see it in the Settings panel without needing to read the README.

* fix(daemon): pass resolved through get_artifact so substring matches echo resolvedProject

* feat(daemon): add MCP unit tests and fill description/instructions gaps

- Export extractRelativeRefs, resolveProjectId, resolveProjectArg,
  withActiveEcho, fetchProjectFile, getArtifact for testing
- mcp-extract-refs.test.ts: 10 cases covering flat, nested, deep,
  escape attempts, external/data/anchor/mailto URLs, srcset
- mcp-get-artifact.test.ts: MAX_FILES=200 cap, maxBytes cap,
  per-file content-length pre-check via fetchProjectFile
- mcp-resolve-project.test.ts: uuid/exact/slug/substring source
  values, ambiguity error, withActiveEcho resolvedProject stamping
- get_artifact maxBytes description now mentions the 200-file cap
- Instructions block now mentions resolvedProject field and when it
  appears (slug or substring match)

* docs(daemon): document MCP active-context TTL and surface wake-up hint

Address PR #399 review item P2.5 (active-context TTL undocumented) plus
the related UX gap where the agent had no way to tell the user that
clicking around in Open Design refreshes the cache.

- PROJECT_ARG, get_artifact entry, get_file path: append TTL note to
  argument descriptions so agents see the ~5-minute fallback window.
- get_active_context: when /api/active reports active:false, return
  an explicit hint string explaining the recovery action ("ask the
  user to click into a project") instead of a bare {active:false}
  the agent can't act on.
- get_active_context tool description: mention the new hint payload.
- resolveProjectArg error: extend the missing-active-context message
  with the same TTL + recovery wording for tool calls that omit
  project= and have no fallback.

* feat(daemon): add offset/limit pagination to MCP get_file

Real-world MCP usage hit a wall on large files: get_file returned the
full body, the agent decided the result was too large for its context
budget, and recovered by spawning a sub-agent that ran Python with
manual brace-matching for several minutes. That defeats the value
proposition of skipping zip-export.

Mirror Claude Code's Read tool semantics: get_file now accepts
optional offset (0-indexed line) and limit (default 2000) args, slices
the file in mcp.ts after fetching from the daemon, and stamps an
[od:file-window offset=.. returnedLines=.. totalLines=..] marker on
sliced or truncated responses so the agent can page by re-calling
with the next offset.

- Tool definition: add offset/limit args, expand description.
- getFile helper: line-split, slice, marker, range clamp at EOF.
- Instructions block: mention pagination in the get_file bullet.
- Binary rejection unchanged.
- New tests in mcp-get-file.test.ts cover default behavior, limit
  truncation, mid-file offset, offset past EOF, and binary rejection.

* fix(daemon): set truncated: true when per-file content-length pre-check fires

When fetchProjectFile throws because a file's advertised content-length
exceeds the remaining byte budget, both the include=all loop and the auto
BFS loop silently skipped the file without setting truncated: true. The
bundle could then report truncated: false even though files were dropped.

Introduce BudgetExceededError as a sentinel so callers can distinguish a
budget rejection (truncated: true) from a genuine fetch failure (404,
network) that should just be skipped. Both getArtifact call sites now
check instanceof BudgetExceededError and set truncated accordingly.

Adds a regression test: 5 files of 250 bytes with explicit content-length,
maxBytes=400. Only file 0 fits; files 1-4 each exceed the remaining 150
bytes. totalTextBytes never reaches maxBytes, so only the new path sets
truncated=true. Previously the bundle reported truncated: false.
2026-05-04 22:34:17 +08:00
nmsnandGitHub 02d24c4fde fix(web): language option button height truncation in settings (#447)
* fix(web): remove min-height constraint on language option buttons

The hardcoded min-height: 40px on .settings-language-option caused
content to overflow when locale labels wrap to two lines. Remove the
constraint so buttons size naturally, and update the menu max-height
budget from 360px (9×40px) to 406px (7×58px) to match.

* fix(web): bump menu max-height to 428px to account for chrome

The previous 406px budget didn't account for menu padding (4px),
row gaps (2px × 6), and border (1px × 2). Bump to 428px so all
7 visible items fit without clipping.
2026-05-04 21:42:06 +08:00
Daniel Duma, PhDandGitHub cfd359e05a [codex] Fix Gemini CLI trust handling (#352)
* Fix Gemini CLI trust handling

* Preserve agent spawn env filtering
2026-05-04 21:39:59 +08:00
nmsnandGitHub e9cc1fbda8 fix(web): prevent vertical scrollbar on ws-tabs-bar (#448)
Setting overflow-x: auto implicitly sets overflow-y to auto per CSS
spec. With a fixed height of 44px, this causes a vertical scrollbar
when content overflows by even 1px. Explicitly set overflow-y: hidden.
2026-05-04 20:50:55 +08:00