Compare commits

...

17 commits

Author SHA1 Message Date
qiongyu1999
f875f542c3 fix(web): remove generating-state takeover from the workspace preview
Drop the GenerationPreviewStage card that hijacked the right-side
preview during generation/awaiting/stopped/failed phases. The workspace
now stays on its original panel (Design Files browser / live preview)
throughout a run, so generation no longer swaps the surface out from
under the user.

Removes the component, its CSS module, the generation-preview runtime
model + tests, the now-unused FileWorkspace props (messages/artifactHtml/
conversationError/onRetry), and the generationPreview.* i18n keys across
all locales.
2026-05-31 17:11:19 +08:00
qiongyu1999
9bb787e173 feat(web): move discovery questions into a right-side Questions tab
Surface the active discovery question-form in a dedicated right-hand
Questions tab instead of inline in the chat. The form streams in there
(frame first, questions revealed one-by-one), with a Continue button and
a "skip all" affordance backed by a 120s auto-continue countdown. The
chat shows a banner that focuses the tab. Every question is optional;
submission is gated only by checkbox selection caps. Answered/historical
forms still render inline so the scrollback reads naturally.
2026-05-31 14:02:42 +08:00
estelledc
53fb175855
fix(web): truncate long filenames in file list (no-preview state) (#3370)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
When the design files preview pane was closed, a very long filename
would expand its `<td.df-cell-name>` and push the kind / mtime / menu
columns off-screen — the auto-layout `<table>` had no width
constraint on the cell, so the existing `text-overflow: ellipsis`
on `.df-row-name` never engaged.

The `:not(.no-preview)` overrides in `routines.css` already pinned
the row's children to `width: 100%; max-width: 100%` when a preview
pane was open, but the no-preview state — the one shown in the issue
screenshot — had no equivalent guard.

CSS:
- `.df-cell-name`: add `max-width: 0; min-width: 0` so the table
  cell collapses to its column allocation in auto-layout.
- `.df-row-name-wrap`: add `max-width: 100%`.
- `.df-row-name`: add `max-width: 100%; min-width: 0` so the flex
  child clamps to the wrap and the existing ellipsis engages.

JSX (DesignFilesPanel.tsx):
- Add `title={f.name}` (and the equivalent for directory rows and
  live-artifact rows) on `.df-row-name`. The browser surfaces the
  full filename on hover even when the visible text is truncated, so
  users can read the leading characters without opening the preview
  pane. `<DfPreview>` already renders the full name with
  `word-break: break-word`.

Closes #3260

Validation:
- pnpm exec vitest run tests/components/DesignFilesPanel.long-name-truncate.test.tsx
  → 3/3 passed (1 was red on main: title attr was absent)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green

Note: jsdom does not measure layout, so the truncation itself can't
be asserted directly. The specs encode the structural contract the
CSS depends on (cell / wrap / name nesting + the `title` attr) so a
JSX shape change won't silently regress the fix.
2026-05-31 05:17:52 +00:00
BayesWang
af4a62b69a
Add configurable project locations (#2041)
* add daemon project location support

* wire project locations into web settings

* localize project location settings

* move default project location to settings

* polish project location selection cards

* fix project location i18n gaps

* fix external project validation cleanup
2026-05-31 04:47:45 +00:00
Dan Porat
3395d2c855
feat(daemon): implement fal.ai renderer for image + video generation (#1606)
* feat(daemon): implement fal.ai renderer for image + video generation

Adds renderFalImage and renderFalVideo backed by the fal queue API
(queue.fal.run). Any fal-ai/* model path can be used directly without
a catalog entry, enabling the full fal model library without code
changes. Catalogued shortcuts are mapped via FAL_ENDPOINTS to their
fal-ai/* paths; OD_FAL_MAX_POLL_MS controls the poll ceiling.

Expands the fal model catalog with flux-pro-ultra, flux-dev-fal,
flux-schnell-fal, ideogram-v3-fal, recraft-v3-fal (images) and
veo-3-fal, veo-2-fal, wan-2.1-t2v, wan-2.1-i2v, seedance-1-pro-fal,
kling-2.1-t2v-fal (video). Marks fal provider as integrated: true in
both daemon and web model registries.

* fix(daemon): address fal renderer review comments

- Correct Wan 2.1 endpoints: wan-video/v2.1/* → fal-ai/wan-t2v / fal-ai/wan-i2v
- Correct Kling 2.1 t2v endpoint: .../pro/... → .../master/text-to-video
- Add FAL_IMAGE_USES_ASPECT_RATIO: flux-pro-ultra sends aspect_ratio not image_size
- Add FAL_VIDEO_NO_DURATION: Wan models reject the duration field
- Add FAL_VIDEO_STRING_DURATION: Veo expects duration as "5s" not 5
- Fix falQueueBase() to use anchored regex replace, avoiding mangled
  custom base URLs
- Do not wrap payload under input — raw fal queue HTTP API expects flat
  body; the input wrapper is an SDK abstraction only (confirmed by 422
  validation error from fal showing prompt missing at body.prompt)

* fix(daemon): correct fal queue protocol comment (flat body, no SDK input wrapper)

* fix(daemon): clamp Veo duration to valid fal buckets (4s/6s/8s)

* fix(daemon): report effective fal Veo duration in providerNote (with snap warning)

* fix(daemon): reduce image generation latency from 4m37s to ~73s

Five layered fixes targeting the overhead that padded a ~10s fal API call
into a 4m37s user-facing wait:

1. Skip DISCOVERY_AND_PHILOSOPHY for media surfaces (image/video/audio).
   The ~3000-token HTML-artifact discovery layer is irrelevant for media
   generation and forced the agent to parse and override all its rules
   before dispatching. Removes it from the system prompt entirely for
   these surfaces; MEDIA_GENERATION_CONTRACT is the sole authority.

2. Broaden the wait-loop contract to cover ALL slow models, not just
   "Volcengine i2v / hyperframes-html". Any model whose generation
   exceeds 25s — including fal flux-pro-ultra, Veo, Sora — returns exit 2
   from od media generate. The contract now makes this universal and
   provides a python3-based bash pattern (jq is not guaranteed to be
   installed on all agent runtimes).

3. Increase od media wait polling budget from 25s to 120s. od media
   generate keeps its 25s budget for fast feedback; od media wait is
   purpose-built to sit and poll, so it can safely use the full 2-minute
   bash-tool window. Reduces re-entries for a 3-minute generation from
   ~7 to ~2.

4. First fal poll is now immediate instead of always sleeping 3s before
   the first status check. Saves 3s for all fal jobs.

5. Project metadata no longer emits "(unknown — ask)" for imageModel and
   aspectRatio when unset. Emits the actual defaults (gpt-image-2,
   aspect-ratio scene heuristic) so the agent can dispatch without
   extended reasoning about model selection. Also adds dispatch-immediately
   defaults and a brief-reply rule (2–3 sentences max after generation).

Measured end-to-end on the exact problem prompt before/after:
  Before: 4m37s (discovery form + 7x LLM re-entries + jq failure)
  After:  ~73s   (single bash loop, no question turn, image delivered)

* feat(daemon): inject media dispatch hint for non-media project surfaces

Agents running inside prototype, deck, and other non-image/video/audio
projects previously had no knowledge of `od media generate`, so when
asked to create an image with fal they would try to call provider REST
APIs directly and ask the user for API keys — even though the daemon
already holds credentials in .od/media-config.json.

Add MEDIA_DISPATCH_HINT to composeSystemPrompt for all non-media
surfaces. The hint tells the agent to always route media generation
through the daemon dispatcher, and explicitly forbids prompting for
API keys.

Verified end-to-end: a prototype project generates a 952 KB image via
flux-pro-ultra in ~52s with no key errors.

* fix(daemon): prevent agent from converting bash env vars to PowerShell syntax

MEDIA_DISPATCH_HINT now explicitly labels the shell as POSIX bash and
shows the correct $VAR form side-by-side with a warning NOT to use
PowerShell $env:VAR. Without this, claude-sonnet running on a Windows
host converts the example to PowerShell syntax (`& $env:OD_NODE_BIN`)
which then fails at the bash executor with 'syntax error near unexpected
token &'.

* fix(daemon): add generate→wait loop to MEDIA_DISPATCH_HINT for slow models

MEDIA_DISPATCH_HINT previously showed only a bare  call.
flux-pro-ultra and other slow models always exit 2 after ~25s — without
the wait loop the agent would treat exit 2 as a failure and report an
error to the user.

Replace the single-command example with the canonical generate→wait loop
(matching media-contract.ts), add an explicit note that exit 2 means
'keep polling', and reinforce the POSIX bash / no-PowerShell rule
directly inside the code block.

* fix(daemon): allow fal-ai/* passthrough in media-agent contract

The media-agent prompt instructed the agent to warn and substitute the
default model for any ID not in the catalogue. This blocked the custom
fal-ai/* passthrough path the daemon already supports, so users could
not reach uncatalogued fal models from the normal chat flow.

Carve out the fal-ai/* exception so the agent passes those IDs through
directly instead of warning or substituting.

* fix(daemon): align MEDIA_DISPATCH_HINT with exit-0 generate contract

media generate now always exits 0 (handoff included). The non-media
agent hint still checked ec==2 to decide whether to keep polling, so
slow fal models (flux-pro-ultra, veo-3-fal) would stop after printing
the handoff JSON instead of entering the wait loop.

- generate error check: drop the ec!=2 exception (exits 0 always)
- while loop: drive on taskId presence, not ec==2; stop on ec==0/5
- footer: remove --surface inference claim; CLI requires it explicitly

* fix(guard): add test-fal-webui.ts to e2e scripts allowlist

CI failed: guard flagged e2e/scripts/test-fal-webui.ts as an
unapproved package-owned entrypoint. Add it to allowedE2eScripts.

* fix(daemon): update prompt test expectations to match exit-0 handoff wording

The two stale assertions checked for the old generate-exits-2 copy which
no longer exists in the contract. Update them to match the current
always-exits-0 wording.

* fix(daemon): move skipDiscoveryBrief override before discovery block

* chore(e2e): remove ad-hoc fal webui test script

The script was a one-time developer helper used to manually validate fal
image generation through the live UI. It relied on a real fal API key and
hardcoded local port, so it cannot participate in the e2e package's
fixture/reporting/CI conventions. Removing it per reviewer feedback.

- Delete e2e/scripts/test-fal-webui.ts
- Remove its guard.ts allowlist entry
- Gitignore the file and its screenshots to prevent accidental re-addition

* chore: remove accidental local scratch files from branch

Remove bash.exe.stackdump (MSYS crash dump) and fix_loop.py (one-off
local rewrite helper) — neither is a repo-owned source artifact.

* fix(prompts): document fal-ai/* passthrough in non-media dispatch hint

Prototype/deck agents now know arbitrary fal-ai/* model ids are valid
--model values and should be forwarded as-is, mirroring the exception
already present in media-contract.ts. Adds a prompt regression test.

* fix(daemon): use renderMediaGenerationContract(mediaExecution) for media surfaces

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-31 04:44:44 +00:00
kami
333a62cda6
fix: link od bin after fresh install (#2069)
* fix: link od bin after fresh install

* test: lock root od bin shim path

* test: cover root workspace deps in postinstall scan

* chore(nix): refresh pnpm deps hash
2026-05-31 04:36:49 +00:00
kami
def2e9fd2e
fix(web): dock comment side panel outside preview (#2073)
* fix: dock comment board without clipping inspect

When the comment-side dock falls back to the stacked layout in narrow
panes, collapsing the side panel now shrinks the bottom strip to a
horizontal rail height instead of keeping the full panel-height row.
commentPreviewCanvasSize() also stops over-deducting the expanded
panel height in the stacked-collapsed path so the canvas sizing stays
in sync with what is rendered.

* fix(web): address docked comment panel review follow-ups

* Fix non-docked comment tool tablet scaling

* test(web): align comment panel tests with collapse API
2026-05-31 04:36:15 +00:00
Denis Redozubov
729ce2b0cb
feat(daemon): add run-scoped MCP tool bundles (#3244)
* feat(daemon): add run-scoped MCP tool bundles

* fix(daemon): keep sandbox runs in managed project dirs

* fix(daemon): reject malformed run tool bundles

* fix(contracts): model run-scoped mcp server inputs

* fix(daemon): reject unsupported run tool bundles

* fix(daemon): validate run tools before chat fallback

* test(daemon): expect sandbox imported folder failure

* fix(daemon): preflight sandbox project roots before run rows

* fix(daemon): preflight sandbox chat project roots

* fix(daemon): allow host editor for sandbox imports

* fix(daemon): preflight sandbox routine project reuse

* fix(daemon): reject undeliverable Claude tool bundles

* fix(daemon): single-source chat route validation
2026-05-31 03:53:04 +00:00
蓝宙
e8c179d3a6
fix: show cumulative conversation duration (#3354)
* fix: show cumulative conversation duration

* fix: include usage-only run durations

---------

Co-authored-by: Lanzhou3 <217479610+Lanzhou3@users.noreply.github.com>
2026-05-31 03:52:12 +00:00
estelledc
0b493a66c0
fix(web): prevent caret reset on tools-menu picker mousedown (#3368)
The right-side @-button tools popover (ToolsPluginsPanel,
ToolsSkillsPanel, ToolsMcpPanel) inserts text into the composer
draft using the textarea's selectionStart at click time, but the
picker rows had `onClick` without `onMouseDown={(e) =>
e.preventDefault()}`. On a real mouse, mousedown fires first, the
textarea loses focus before the click handler runs, and
selectionStart resets — so the inserted token lands at offset 0
instead of at the user's cursor.

The @-mention popover already prevents this by calling
preventDefault on mousedown for every picker row (the comment at
ChatComposer.tsx:3039-3043 explains the reason). This change
mirrors that protection on the three tools-menu pickers.

The mention popover itself was unaffected, so design-file
mentions (which only flow through the @-popover via
`replaceMentionWithText`) are not impacted by this issue. The
reporter's mention of "design files" appears to refer to picking a
file via the @-popover, where the protection was already in place.

Closes #3195

Validation:
- pnpm exec vitest run tests/components/ChatComposer.tools-menu-caret.test.tsx
  → 3/3 passed (red on main, asserts each picker calls
  preventDefault on mousedown)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
2026-05-31 03:50:45 +00:00
mehmet turac
8448b1105c
fix: preserve OpenClaude fallback credentials (#3361)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
2026-05-31 03:49:25 +00:00
Jane
d66a463d62
feat(landing-page): 301 legacy /skills /systems /templates to /plugins (#3352)
The 2026-05 plugins library rebuild introduced /plugins/skills/,
/plugins/systems/, /plugins/templates/ and a unified detail route
/plugins/<manifest-slug>/, but the old /skills/, /systems/, /templates/
catalogs were left live in parallel. Two equivalent page trees split SEO
equity, and the homepage, footer, quickstart, agents, official and blog
pages all still linked to the old routes.

Retire the legacy generators and 301 every old URL to its new plugins
equivalent so inbound links and search equity are preserved:

- Remove the /skills, /systems, /templates page generators (English +
  [locale] wrappers) and the now-orphaned skill-row component, and prune
  the skills/systems/templates branches from the [locale]/[...path]
  catch-all (it now renders only craft + blog).
- Add the migration block to public/_redirects. Detail slugs differ from
  the old folder names (new slugs are manifest-name based, e.g.
  design-system-<x>, example-<x>), so systems/templates use a prefixed
  splat plus a short degrade list, and skills map the 27 with a template
  equivalent explicitly while the ~110 instruction-only skills and all
  mode/scenario/category facet pages degrade to the section landing.
  'replicate' is forced to the section to avoid colliding with the
  design-system of the same name. Locale variants (zh, zh-tw, ja, ko)
  strip to the section.
- Repoint in-site links to /plugins/* across page.tsx (footer, work,
  labs pills), info-page-i18n.ts (en + zh + sourceNames), official,
  quickstart, agents, blog and html-anything, and update the sitemap
  serialize priority list. The system-card keeps linking through
  /systems/<slug>/ so the 8 systems without a detail page ride the
  redirect's degrade rather than pointing at a missing page.

Verified with a full astro build: old routes no longer emit any HTML,
the new section pages exist, _redirects is copied verbatim, and no
in-site link targets a removed route (the remaining /systems/<slug>/
hrefs are the system cards that 301 by design). astro check passes.

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
2026-05-31 01:04:20 +00:00
estelledc
1a6face04c
fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.

Architecture summary (rounds 1–9 collapsed; round 10 detailed below):

  - `Array<{token, start, pluginId, insertionId?}>` tracking with
    start offsets reconciled across each keystroke via an LCS+LCP
    edit-range diff in
    `apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
    `insertionId` is forwarded by `reconcileInsertions` so the
    producer can locate its own entry across reconciles
    (round 10).
  - All draft mutations route through a single `updateDraft`
    chokepoint that runs `reconcileInsertions` outside the
    `setDraft` updater so React StrictMode's double-invoke is
    harmless (round 4-5).
  - Boundaries delegate to the shared
    `inlineMentions.isMentionBoundary` /
    `inlineMentions.isMentionRightBoundary` helpers so the
    tracker can never diverge from the parser (round 5).
  - `setActivePlugin` is a chokepoint for every applyById path,
    filtering tracked entries to those matching the new active
    plugin so a replace-plugin flow can never let stale entries
    survive (round 6).
  - Picker rollback double-snapshots draft + tracker so apply-
    failure restores the tracker but only rewrites the draft
    when no user keystrokes arrived during the await
    (round 7-8).
  - `stripPluginInsertedTokens` collapses whitespace seam-local
    so user-authored multi-space spans elsewhere are preserved
    (round 8).
  - `setActivePlugin` is deferred past `await applyById` on
    every path, and `onCleared` filters by
    `pluginsSectionRef.current?.getActiveRecord()?.id` so a
    pending-window clear scopes to the actually-mounted
    plugin's tokens (round 9).

race in the picker rollback:

  Round 9 made `onCleared` mutate the tracker and the draft when
  it ran during a pending replace, and added the `getActiveRecord`
  filter so the strip targets the still-mounted plugin's entries
  only. The picker's failure-path rollback, however, still
  restored `prevEntries` / `prevActiveId` wholesale — assuming
  nothing else had touched the tracker during the await. If the
  user clicked the still-mounted original chip's × during the
  pending replace AND the deferred `applyById` then resolved
  with a 500, the wholesale restore (a) resurrected entries that
  `onCleared` had legitimately stripped (now stale offsets) and
  (b) left the optimistic `@<target>` orphaned in the draft with
  no chip ever having mounted — the original #2881 symptom
  recurring inside the failure window.

  Fix splits the failure rollback into two paths:

  1. **Detect "intervening clear" via `activePluginIdRef.current
     === null && prevActiveId !== null`.** `onCleared` always
     nulls the active id as its last action; our deferred
     `setActivePlugin` never ran in the failure branch. So the
     null-while-prev-not-null state is the smoking gun for an
     intervening clear during the await.

  2. **On detection, surgically remove only our optimistic
     entry and only its `@<target>`.** Locate the entry by
     `insertionId` (added to `TrackedInsertion` as an optional
     field, forwarded by `reconcileInsertions` so the id
     survives offset shifts) — this disambiguates the case
     where the user picked the same plugin from the @-popover
     more than once during the await window. Splice that entry
     out and run `updateDraft((d) => stripPluginInsertedTokens(
     d, [ourEntry]))` so the draft loses `@<target>` and any
     remaining tracked entries (the in-flight target would have
     no others, but a co-pending second pick could) get their
     offsets reconciled. `activePluginIdRef` stays at `null` —
     `onCleared`'s truth, since no chip is mounted.

  The "no intervening clear" branch is the round 7/8 path:
  restore `prevEntries`/`prevActiveId` wholesale and rewrite
  the draft only if `draftRef.current === postInsertDraft`
  (no user keystrokes during the await).

Regression coverage (additions):

  - `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
    — 18 integration specs total (17 prior + 1 new round-10):
    * `@-popover pick A → @-popover pick B (apply pending) →
      clear A's chip → resolve B with 500 → assert no orphan
      @<target>, no orphan @A, no chip mounted, no stale
      tracker entries`. Uses a deferred `Promise<Response>` so
      the apply stays in flight while the chip-clear is fired,
      then resolves with a 500 to drive the failure path. Pre-
      fix this would resurrect Airbnb's stale entry AND leave
      `@SecondPlugin` orphaned in the draft.

PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.

Validation:
  - pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
  - pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
  - pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
  - pnpm --filter @open-design/web typecheck → green
  - pnpm guard → green
2026-05-30 17:16:24 +00:00
Denis Redozubov
f4c5d22f22
fix(daemon): confine sandbox project roots and host discovery (#3243)
* fix(daemon): confine sandbox project and host discovery

* fix(daemon): resolve sandbox data dir for toolchain discovery

* fix(daemon): resolve sandbox data dir for agent env

* fix(daemon): fail fast for sandbox imported folders

* test(daemon): assert sandbox imported folder rejection

* fix(daemon): keep sandbox import guard at run start

* fix(daemon): reject sandbox imported project file roots

* fix(daemon): preserve imported project detail roots

* test(daemon): expect sandbox profiles to stay scoped

* fix(daemon): bypass proxies for agent tool callbacks

* test(daemon): isolate media policy route memory extraction

* fix(daemon): keep loopback no-proxy scoped to sandbox
2026-05-30 16:57:04 +00:00
Denis Redozubov
9a3424d68c
feat(daemon): add sandbox runtime foundation (#3242)
* feat(daemon): add sandbox runtime foundation

* fix(daemon): preserve sandbox roots after agent env overrides

* fix(daemon): keep readiness probes pathless

* fix(daemon): harden headless run fallbacks

* fix(daemon): bootstrap sandbox runtime discovery

* fix(daemon): preserve explicit sandbox agent profile mounts

* fix(daemon): keep sandbox profile lookup run scoped

* fix(daemon): normalize sandbox data dir input

* fix(daemon): pin sandbox env roots to base data dir
2026-05-30 15:06:05 +00:00
open-design-bot[bot]
b9f0b69cf1
docs(readme): refresh contributors wall (#3339)
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
2026-05-30 14:02:17 +00:00
chaoxiaoche
df1535b7fd
feat(web): add staged preview feedback during generation (#3227)
* feat(web): wire generation preview stage into workspace

Show a 3-step progress overlay (understand → generate → prepare) in the
preview area while artifacts are being generated, replacing the blank
empty state. Displays elapsed time, an estimated duration hint, and a
retry button on failure.

- Add GenerationPreviewStage component + CSS module + runtime helpers
- Integrate buildGenerationPreviewState into FileWorkspace
- Pass messages/artifact/error/retry from ProjectView to FileWorkspace
- Register i18n keys for en and zh-CN locales

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

* feat(web): keep generation preview alive and persistent across waiting states

Address UX feedback on the generation preview surface:

- Make the waiting card feel alive instead of frozen: breathing mark,
  sweeping progress shimmer, pulsing running-step dot, and a live
  activity snippet pulled from streamed events (respects
  prefers-reduced-motion).
- Add an `awaiting-input` phase so the preview no longer reverts to the
  empty "design will appear here" placeholder when the agent asks the
  user a clarifying question (detects inline <question-form>).
- Add a `stopped` phase so a canceled/paused run keeps a contextual
  paused card instead of blanking the surface.
- Fix workspaceHasPreviewSurface live-artifact tab match (was reading a
  non-existent `tabId` field) and correct the unit assertion that
  contradicted the helper's `thinking` handling.
- Populate generationPreview.* keys (incl. new awaiting/stopped strings)
  across all locales.

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

* feat(web): reveal generation steps progressively as the agent reaches them

- Only render steps the agent has actually reached (drop pending pills)
  with a slide/fade entrance, so the card visibly evolves 1->2->3 instead
  of always showing the same fully-populated row.
- Keep the "understand" step in progress during requesting/starting so a
  fresh run opens with a single step rather than a pre-filled set.
- Stop surfacing status detail (e.g. the model slug from `requesting`) as
  the live activity line; only genuine thinking/output text is shown.

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

* feat(web): add dynamic sub-status to the generating step

Keep 3 high-level steps but give the long "generating" phase concrete,
moving feedback (option A) instead of splitting into more, less-reliable
steps:

- Derive a sub-status from the agent's TodoWrite plan: the in-progress
  task label (activeForm) plus a done/total count, falling back to the
  latest write/edit target file when no plan was emitted.
- The count counts the in-progress task toward `done` to match the
  chat-side todo card (e.g. 3/7 on both sides).
- Suppress the higher-level narration line while the sub-status is shown
  so only one dynamic line appears at a time (early phase = narration,
  writing phase = concrete task + count).

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

* feat(web): drop elapsed timer and duplicate estimate from generation preview

The "usually 2–5 minutes" estimate showed twice (lead footnote + meta row)
and the elapsed counter added little signal, so remove both: delete the
meta row, stop falling back to the estimate footnote in the generating
lead (render the lead only when live narration exists), and drop the now
unused elapsed timer/util.

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

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:59:49 +00:00
161 changed files with 10500 additions and 2631 deletions

3
.gitignore vendored
View file

@ -76,4 +76,7 @@ docs/superpowers/
# on every deploy. Should not be committed (~70MB of PNGs).
apps/landing-page/public/previews/
# Ad-hoc local e2e scripts and their screenshots
e2e/scripts/test-fal-webui.ts
e2e/scripts/fal-webui-*.png
growth/**

View file

@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidores de Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidores de Open Design" />
</a>
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributeurs Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributeurs Open Design" />
</a>
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point dentrée.
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design コントリビューター" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design コントリビューター" />
</a>
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -726,7 +726,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 컨트리뷰터" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 컨트리뷰터" />
</a>
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -1040,7 +1040,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
@ -1057,9 +1057,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidoras e contribuidores do Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidoras e contribuidores do Open Design" />
</a>
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributors Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributors Open Design" />
</a>
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
</a>
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Контриб'ютори Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Контриб'ютори Open Design" />
</a>
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 贡献者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 贡献者" />
</a>
第一次提 PR欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

View file

@ -1006,7 +1006,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 貢獻者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 貢獻者" />
</a>
第一次提 PR歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
@ -1023,9 +1023,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
</picture>
</a>

16
apps/daemon/bin/od.mjs Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const entryDir = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(entryDir, "../dist/cli.js");
if (!existsSync(distEntry)) {
throw new Error(
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
);
}
await import(pathToFileURL(distEntry).href);

View file

@ -6,7 +6,7 @@
"main": "./dist/cli.js",
"types": "./dist/cli.d.ts",
"bin": {
"od": "./dist/cli.js"
"od": "./bin/od.mjs"
},
"exports": {
".": {
@ -20,6 +20,7 @@
}
},
"files": [
"bin",
"dist",
"package.json"
],

View file

@ -13,8 +13,9 @@
// outside this machine.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import path from 'node:path';
import { expandHomePrefix } from './home-expansion.js';
import {
readInstallationFile,
@ -85,6 +86,12 @@ export interface OrbitConfigPrefs {
templateSkillId?: string | null;
}
export interface ProjectLocationPrefs {
id: string;
name: string;
path: string;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
@ -99,6 +106,8 @@ export interface AppConfigPrefs {
privacyDecisionAt?: number | null;
orbit?: OrbitConfigPrefs;
customInstructions?: string | null;
projectLocations?: ProjectLocationPrefs[];
defaultProjectLocationId?: string | null;
}
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
'privacyDecisionAt',
'orbit',
'customInstructions',
'projectLocations',
'defaultProjectLocationId',
] as const);
function configFile(dataDir: string): string {
@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined {
return orbit;
}
function normalizeLocationId(raw: string, fallback: string): string {
const trimmed = raw.trim();
if (/^[A-Za-z0-9._-]{1,128}$/.test(trimmed) && trimmed !== 'default') {
return trimmed;
}
return fallback;
}
function autoProjectLocationId(pathKey: string): string {
return `loc_${createHash('sha256').update(pathKey).digest('base64url').slice(0, 16)}`;
}
function validateProjectLocations(raw: unknown): ProjectLocationPrefs[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw)) return undefined;
const result: ProjectLocationPrefs[] = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const obj = item as Record<string, unknown>;
if (typeof obj.path !== 'string') continue;
const expanded = expandHomePrefix(obj.path.trim());
if (!expanded || !path.isAbsolute(expanded)) continue;
const normalizedPath = path.normalize(expanded);
const pathKey = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath;
if (seenPaths.has(pathKey)) continue;
const id = normalizeLocationId(
typeof obj.id === 'string' ? obj.id : '',
autoProjectLocationId(pathKey),
);
if (seenIds.has(id)) continue;
const rawName = typeof obj.name === 'string' ? obj.name.trim() : '';
result.push({ id, name: rawName || path.basename(normalizedPath) || normalizedPath, path: normalizedPath });
seenIds.add(id);
seenPaths.add(pathKey);
}
return result;
}
export function agentCliEnvForAgent(
prefs: AgentCliEnvPrefs | undefined,
agentId: string,
@ -330,6 +381,25 @@ function applyConfigValue(
}
return;
}
if (key === 'projectLocations') {
const validated = validateProjectLocations(value);
if (validated !== undefined) {
target[key] = validated;
} else {
delete target[key];
}
return;
}
if (key === 'defaultProjectLocationId') {
if (typeof value === 'string') {
target[key] = normalizeLocationId(value, 'default');
} else if (value === null) {
target[key] = null;
} else {
delete target[key];
}
return;
}
}
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {

View file

@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import { createRoleMarkerGuard } from './role-marker-guard.js';
// Allowlist for the `/feedback` route. Mirrors the
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { startChatRun, submitToolResultToRun } = ctx.chat;
const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const {
handleCritiqueArtifact,
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
critiqueResponseCapBytes,
critiqueRunRegistry,
} = ctx.critique;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
// so any handler we wired here was shadowed and never executed. Plugin
// snapshot resolution, clientType inference, and the daemon-side
// run_created/finished analytics all live in `server.ts` now.
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
// launch path single-sourced so validation changes land on the live route.
app.get('/api/runs', (req, res) => {
const { projectId, conversationId, status } = req.query;
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.status(202).json(outcome);
});
app.post('/api/chat', (req, res) => {
if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(
(body as { mediaExecution?: unknown }).mediaExecution,
);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const runBody = { ...body, mediaExecution: mediaExecution.policy };
const run = design.runs.create(runBody);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(runBody, run));
});
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
// Settings dialog uses these to verify a config works without sending a
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null;
stdoutTail?: string | null;
env?: Record<string, unknown> | null;
resolvedBin?: string | null;
}
export interface ClaudeCliDiagnostic {
@ -51,6 +54,15 @@ function withContext(
};
}
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
const base = path
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude' ? 'openclaude' : 'claude';
}
export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure =
hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
);
}
if (authFailure) {
if (isOpenClaude) {
return withContext(
'OpenClaude could not authenticate with its configured endpoint.',
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.'
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
}
if (!text.trim() && input.exitCode === 1) {
if (isOpenClaude) {
return withContext(
'OpenClaude exited before producing diagnostics.',
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.';

View file

@ -573,11 +573,11 @@ async function runMediaWait(rawArgs) {
const since = Number.isFinite(Number(flags.since))
? Number(flags.since)
: 0;
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
await pollUntilDoneOrBudget(daemonUrl, taskId, since, { totalBudgetMs: 120_000 });
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
const totalBudgetMs = 25_000;
const totalBudgetMs = typeof options.totalBudgetMs === 'number' ? options.totalBudgetMs : 25_000;
const perCallTimeoutMs = 4_000;
const stillRunningExitCode =
typeof options.stillRunningExitCode === 'number'

View file

@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: executableResolution.selectedPath },
);
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
stderrTail,
stdoutTail: rawStdoutTail || buffered,
env,
resolvedBin: executableResolution.selectedPath,
});
if (claudeDiagnostic) {
console.warn(

View file

@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
AND m.run_status IS NOT NULL
)
WHERE rn = 1
),
total_run_durations AS (
SELECT m.conversation_id AS conversationId,
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
FROM messages m
JOIN project_conversations c ON c.id = m.conversation_id
WHERE m.role = 'assistant'
AND m.run_status IN ('succeeded', 'failed', 'canceled')
GROUP BY m.conversation_id
)
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson
lr.latestRunEndedAt, lr.latestRunEventsJson,
trd.totalDurationMs
FROM project_conversations c
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
ORDER BY c.updatedAt DESC`,
)
.all(projectId)).map(normalizeConversation);
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
return {
...normalizeConversation(r),
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
};
}
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
...numberProperty('totalDurationMs', r.totalDurationMs),
latestRun: latestRun ?? undefined,
};
}
function numberProperty(key: string, value: unknown) {
const n = value == null ? undefined : Number(value);
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
}
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db
.prepare(
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
return conversationRunSummaryFromRow(row);
}
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
const row = db
.prepare(
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
FROM messages
WHERE conversation_id = ?
AND role = 'assistant'
AND run_status IN ('succeeded', 'failed', 'canceled')`,
)
.get(conversationId) as DbRow | undefined;
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
}
function terminalRunDurationSql(alias?: string) {
const p = alias ? `${alias}.` : '';
return `CASE
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
CASE
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
ELSE 0
END
ELSE (
SELECT CASE
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
THEN json_extract(usage_event.value, '$.durationMs')
ELSE 0
END
FROM json_each(
CASE
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
THEN ${p}events_json
ELSE '[]'
END
) AS usage_event
WHERE usage_event.type = 'object'
AND json_extract(usage_event.value, '$.kind') = 'usage'
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
ORDER BY CAST(usage_event.key AS INTEGER) DESC
LIMIT 1
)
END`;
}
function conversationRunSummaryFromRow(row: DbRow | undefined) {
if (!row || typeof row.runStatus !== 'string') return null;
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);

View file

@ -15,6 +15,7 @@
import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises';
import path from 'node:path';
import type { Express } from 'express';
import type {
HostEditor,
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
return true;
}
function projectHostOpenDir(
projectsRoot: string,
project: { id: string; metadata?: { baseDir?: unknown } | null },
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const importedBaseDir =
typeof project.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: '';
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
return importedBaseDir;
}
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectHostOpenDir(
PROJECTS_DIR,
project,
resolveProjectDir,
);
const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);

View file

@ -6,6 +6,7 @@ import {
inlineRelativeAssets,
type InlineAssetReader,
} from './inline-assets.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
@ -28,6 +29,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
const { insertConversation } = ctx.conversations;
const { setTabs } = ctx.projectFiles;
const { validateProjectDesignSystemId } = ctx.validation;
const rejectSandboxFolderImport = () =>
isSandboxModeEnabled(process.env)
? 'folder imports are disabled when OD_SANDBOX_MODE is enabled'
: null;
app.post(
'/api/import/claude-design',
importUpload.single('file'),
@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();
@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false;
if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret();

View file

@ -41,6 +41,7 @@ import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
import { expandHomePrefix } from './home-expansion.js';
import { resolveXAIBearer } from './xai-credentials.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string {
}
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
if (isSandboxModeEnabled(process.env)) return null;
const home = os.homedir();
const codexAuth = await readJsonIfPresent(
path.join(home, '.codex', 'auth.json'),
@ -318,6 +320,8 @@ async function resolveXAIOAuthCredential(
};
}
if (isSandboxModeEnabled(process.env)) return null;
// 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json
// when the user ran `hermes auth add xai-oauth`. A user who has already authorized
// Hermes doesn't have to run a second OAuth dance inside OD.

View file

@ -40,7 +40,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
{ id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' },
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
{ id: 'fal', label: 'Fal.ai', hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model', integrated: true, defaultBaseUrl: 'https://fal.run', supportsCustomModel: true },
{ id: 'leonardo', label: 'Leonardo.ai', hint: 'Phoenix / Kino XL / FLUX', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://cloud.leonardo.ai/api/rest/v1' },
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com' },
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
@ -107,7 +107,13 @@ export const IMAGE_MODELS: MediaModel[] = [
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-pro-ultra', label: 'flux-pro-ultra', hint: 'Fal · FLUX 1.1 Pro Ultra · highest quality (~60180s)', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-dev-fal', label: 'flux-dev (fal)', hint: 'Fal · FLUX Dev · balanced quality/speed (~1540s)', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-schnell-fal', label: 'flux-schnell (fal)', hint: 'Fal · FLUX Schnell · fastest (~38s)', provider: 'fal', caps: ['t2i'] },
{ id: 'ideogram-v3-fal', label: 'ideogram-v3', hint: 'Fal · Ideogram v3 · typography + design (~1530s)', provider: 'fal', caps: ['t2i'] },
{ id: 'recraft-v3-fal', label: 'recraft-v3', hint: 'Fal · Recraft v3 · vector + illustration (~1530s)', provider: 'fal', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5 (~2040s)', provider: 'fal', caps: ['t2i'] },
{ id: 'leonardo-phoenix', label: 'Phoenix', hint: 'Leonardo · versatile', provider: 'leonardo', caps: ['t2i'] },
{ id: 'leonardo-kino-xl', label: 'Kino XL', hint: 'Leonardo · cinematic', provider: 'leonardo', caps: ['t2i'] },
@ -138,8 +144,14 @@ export const VIDEO_MODELS: MediaModel[] = [
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
{ id: 'veo-2-fal', label: 'veo-2 (fal)', hint: 'Fal · Google Veo 2', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-t2v', label: 'wan-2.1-t2v', hint: 'Fal · Wan 2.1 text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-i2v', label: 'wan-2.1-i2v', hint: 'Fal · Wan 2.1 image-to-video', provider: 'fal', caps: ['i2v'] },
{ id: 'seedance-1-pro-fal', label: 'seedance-1-pro (fal)', hint: 'Fal · Seedance 1 Pro', provider: 'fal', caps: ['t2v', 'i2v'] },
{ id: 'kling-2.1-t2v-fal', label: 'kling-2.1 (fal)', hint: 'Fal · Kling 2.1 Pro text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'Fal · OpenAI Sora 2', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'Fal · OpenAI Sora 2 Pro', provider: 'fal', caps: ['t2v'] },
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },

View file

@ -327,19 +327,33 @@ export async function generateMedia(args: {
`unsupported audioKind: ${audioKind}. Allowed: music | speech | sfx.`,
);
}
const def = findMediaModel(model);
// Arbitrary fal.ai model paths (e.g. "fal-ai/flux/dev") bypass the
// catalog so users can reach any model on fal without waiting for a
// catalog entry. Surface comes from the caller; no cross-surface guard
// is needed because the fal renderer reads ctx.surface directly.
let def = findMediaModel(model);
let isFalCustomPath = false;
if (!def) {
if (/^fal-ai\//.test(model)) {
isFalCustomPath = true;
def = {
id: model,
label: model,
hint: 'Fal.ai',
provider: 'fal',
caps: surface === 'image' ? ['t2i'] : surface === 'video' ? ['t2v'] : [],
};
} else {
throw new Error(
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models).`,
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models), ` +
`or pass a full fal-ai/* path (e.g. fal-ai/flux/dev) for any Fal model.`,
);
}
// Reject cross-surface combinations (e.g. surface=image + model=seedance-2)
// here so the dispatcher never silently routes a video model id through
// the image renderer. We compare against the surface-specific list — for
// audio we further restrict to the kind-specific bucket so a `music`
// surface can't bill an `elevenlabs-v3` (speech) call.
}
// Reject cross-surface combinations for catalogued models.
const resolvedAudioKind =
surface === 'audio' ? audioKind || 'music' : undefined;
if (!isFalCustomPath) {
const allowed = modelsForSurface(surface, resolvedAudioKind);
if (!allowed.some((m) => m.id === model)) {
const ids = allowed.map((m) => m.id).join(', ');
@ -349,6 +363,7 @@ export async function generateMedia(args: {
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
);
}
}
// Clamp registry-bound numeric inputs to their allowed buckets so a
// hallucinated --length 9999999 doesn't reach a real provider as-is
@ -575,6 +590,16 @@ export async function generateMedia(args: {
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else if (def.provider === 'fal' && surface === 'image') {
const result = await renderFalImage(ctx, credentials);
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else if (def.provider === 'fal' && surface === 'video') {
const result = await renderFalVideo(ctx, credentials, args.onProgress);
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else {
// No real renderer wired up for this (provider, surface). Gate the
// stub fallback behind OD_MEDIA_ALLOW_STUBS so release builds don't
@ -2498,6 +2523,270 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
};
}
// ---------------------------------------------------------------------------
// Provider: Fal.ai — generic queue-based renderer for image + video.
//
// Queue protocol (raw HTTP, no SDK):
// POST https://queue.fal.run/{endpoint} body: flat model input (no wrapper)
// GET {status_url}?logs=0 → { status: QUEUED|IN_PROGRESS|COMPLETED|FAILED }
// GET {response_url} → result payload
//
// Image result shape: { images: [{ url, content_type }] }
// Video result shape: { video: { url } } or { videos: [{ url }] }
//
// Endpoint resolution: FAL_ENDPOINTS maps catalogue IDs to their fal-ai/*
// path. Any model ID not in the map is used verbatim — this is what
// enables arbitrary "fal-ai/..." custom paths without catalog entries.
// ---------------------------------------------------------------------------
const FAL_ENDPOINTS: Record<string, string> = {
'sd-3.5': 'fal-ai/stable-diffusion-v35-large',
'flux-pro-ultra': 'fal-ai/flux-pro/v1.1-ultra',
'flux-dev-fal': 'fal-ai/flux/dev',
'flux-schnell-fal': 'fal-ai/flux/schnell',
'ideogram-v3-fal': 'fal-ai/ideogram/v3',
'recraft-v3-fal': 'fal-ai/recraft-v3',
'sora-2': 'fal-ai/sora',
'sora-2-pro': 'fal-ai/sora',
'veo-3-fal': 'fal-ai/veo3',
'veo-2-fal': 'fal-ai/veo2',
'wan-2.1-t2v': 'fal-ai/wan-t2v',
'wan-2.1-i2v': 'fal-ai/wan-i2v',
'seedance-1-pro-fal': 'fal-ai/bytedance/seedance-1-pro',
'kling-2.1-t2v-fal': 'fal-ai/kling-video/v2.1/master/text-to-video',
};
// Image models that expect `aspect_ratio` (e.g. "16:9") instead of the
// named `image_size` enum ("landscape_16_9") used by FLUX Dev/Schnell/SD.
const FAL_IMAGE_USES_ASPECT_RATIO = new Set([
'fal-ai/flux-pro/v1.1-ultra',
'fal-ai/flux-pro/v1.1',
]);
const FAL_IMAGE_SIZES: Record<string, string> = {
'1:1': 'square_hd',
'16:9': 'landscape_16_9',
'9:16': 'portrait_16_9',
'4:3': 'landscape_4_3',
'3:4': 'portrait_4_3',
};
// Video models that do not accept a duration field at all.
const FAL_VIDEO_NO_DURATION = new Set([
'fal-ai/wan-t2v',
'fal-ai/wan-i2v',
]);
// Video models that expect duration as a suffixed string ("4s"/"6s"/"8s") and
// only accept those specific buckets.
const FAL_VIDEO_STRING_DURATION = new Set([
'fal-ai/veo3',
'fal-ai/veo2',
]);
// Valid Veo duration buckets (seconds). Nearest-bucket clamp applied below.
const FAL_VEO_DURATION_BUCKETS = [4, 6, 8];
async function falQueueRun(
endpoint: string,
queueBase: string,
apiKey: string,
input: Record<string, unknown>,
maxMs: number,
onProgress?: ProgressFn,
modelLabel?: string,
): Promise<any> {
const authHeader = { 'authorization': `Key ${apiKey}` };
const submitResp = await fetch(`${queueBase}/${endpoint}`, {
method: 'POST',
headers: { ...authHeader, 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const submitText = await submitResp.text();
if (!submitResp.ok) {
throw new Error(`fal submit ${submitResp.status}: ${truncate(submitText, 240)}`);
}
let submitData: any;
try { submitData = JSON.parse(submitText); } catch {
throw new Error(`fal submit non-JSON: ${truncate(submitText, 200)}`);
}
const requestId: string = submitData?.request_id;
if (!requestId) {
throw new Error(`fal submit missing request_id: ${truncate(submitText, 200)}`);
}
// Prefer the URLs returned by the submit response; fall back to the
// well-known model-agnostic queue paths as a safety net.
const statusUrl = submitData.status_url
?? `${queueBase}/requests/${encodeURIComponent(requestId)}/status?logs=0`;
const resultUrl = submitData.response_url
?? `${queueBase}/requests/${encodeURIComponent(requestId)}`;
const startedAt = Date.now();
let lastStatus = '';
if (onProgress) {
onProgress(`fal ${modelLabel || endpoint} task ${requestId.slice(0, 8)} accepted; polling…`);
}
let firstPoll = true;
while (Date.now() - startedAt < maxMs) {
if (!firstPoll) await sleep(3000);
firstPoll = false;
const statusResp = await fetch(statusUrl, { headers: authHeader });
const statusText = await statusResp.text();
if (!statusResp.ok) {
throw new Error(`fal poll ${statusResp.status}: ${truncate(statusText, 240)}`);
}
let statusData: any;
try { statusData = JSON.parse(statusText); } catch {
throw new Error(`fal poll non-JSON: ${truncate(statusText, 200)}`);
}
lastStatus = statusData?.status || '';
if (onProgress) {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
onProgress(`fal task ${requestId.slice(0, 8)} status=${lastStatus} (${elapsed}s)`);
}
if (lastStatus === 'COMPLETED') {
const resultResp = await fetch(resultUrl, { headers: authHeader });
const resultText = await resultResp.text();
if (!resultResp.ok) {
throw new Error(`fal result ${resultResp.status}: ${truncate(resultText, 240)}`);
}
try { return JSON.parse(resultText); } catch {
throw new Error(`fal result non-JSON: ${truncate(resultText, 200)}`);
}
}
if (lastStatus === 'FAILED') {
const errRaw = statusData?.error?.message
?? (typeof statusData?.error === 'string' ? statusData.error : null)
?? 'unknown error';
throw new Error(`fal task failed: ${errRaw}`);
}
}
const elapsed = Math.round((Date.now() - startedAt) / 1000);
const ceil = Math.round(maxMs / 1000);
throw new Error(
`fal timed out after ${elapsed}s waiting for COMPLETED ` +
`(last status: ${lastStatus || 'unknown'}, ceiling ${ceil}s). ` +
`Raise OD_FAL_MAX_POLL_MS to extend the ceiling.`,
);
}
function falMaxPollMs(defaultMs: number): number {
const v = Number(process.env.OD_FAL_MAX_POLL_MS);
return Number.isFinite(v) && v >= 30_000 ? v : defaultMs;
}
function falQueueBase(baseUrl: string): string {
if (baseUrl.includes('queue.fal.run')) return baseUrl;
// Replace only the exact host to avoid mangling custom base URLs that
// happen to contain "fal.run" as a substring.
return baseUrl.replace(/^https:\/\/fal\.run/, 'https://queue.fal.run');
}
async function renderFalImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
}
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
const aspectRatio = ctx.aspect ?? '1:1';
const input: Record<string, unknown> = {
prompt: ctx.prompt || 'A high-quality image.',
num_images: 1,
};
// flux-pro-ultra and similar pro variants expect `aspect_ratio` as a
// ratio string; most other fal image models use a named `image_size`.
if (FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint)) {
input.aspect_ratio = aspectRatio;
} else {
input.image_size = FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd';
}
if (ctx.imageRef?.dataUrl) {
input.image_url = ctx.imageRef.dataUrl;
}
const result = await falQueueRun(endpoint, queueBase, credentials.apiKey, input, falMaxPollMs(5 * 60 * 1000));
const imageEntry = Array.isArray(result?.images) ? result.images[0] : null;
if (!imageEntry?.url) {
throw new Error(`fal image missing images[0].url: ${truncate(JSON.stringify(result), 200)}`);
}
const dlResp = await fetch(imageEntry.url);
if (!dlResp.ok) throw new Error(`fal image download ${dlResp.status}`);
const bytes = Buffer.from(await dlResp.arrayBuffer());
const sizeLabel = FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint) ? aspectRatio : (FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd');
return {
bytes,
providerNote: `fal/${endpoint} · ${sizeLabel} · ${bytes.length} bytes`,
suggestedExt: sniffImageExt(bytes),
};
}
async function renderFalVideo(ctx: MediaContext, credentials: ProviderConfig, onProgress?: ProgressFn): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
}
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
const aspectRatio = ctx.aspect ?? '16:9';
const durationSec = ctx.length ?? 5;
const input: Record<string, unknown> = {
prompt: ctx.prompt || 'A short cinematic clip.',
aspect_ratio: aspectRatio,
};
// Track the effective duration label (what we actually send upstream).
let effectiveDurationLabel: string | undefined;
let durationSnappedNote = '';
// Some models (Wan) have no duration parameter; others (Veo) require a
// suffixed string from a fixed bucket set ("4s"/"6s"/"8s").
if (!FAL_VIDEO_NO_DURATION.has(endpoint)) {
if (FAL_VIDEO_STRING_DURATION.has(endpoint)) {
const closest = FAL_VEO_DURATION_BUCKETS.reduce((a, b) =>
Math.abs(b - durationSec) < Math.abs(a - durationSec) ? b : a,
);
input.duration = `${closest}s`;
effectiveDurationLabel = `${closest}s`;
if (closest !== durationSec) {
durationSnappedNote = ` (requested ${durationSec}s → snapped to ${closest}s)`;
}
} else {
input.duration = durationSec;
effectiveDurationLabel = `${durationSec}s`;
}
}
if (ctx.imageRef?.dataUrl) {
input.image_url = ctx.imageRef.dataUrl;
}
const result = await falQueueRun(
endpoint, queueBase, credentials.apiKey, input,
falMaxPollMs(10 * 60 * 1000), onProgress, ctx.model,
);
const videoUrl: string | null =
result?.video?.url
?? (Array.isArray(result?.videos) ? result.videos[0]?.url : null)
?? null;
if (!videoUrl) {
throw new Error(`fal video missing video.url: ${truncate(JSON.stringify(result), 200)}`);
}
const dlResp = await fetch(videoUrl);
if (!dlResp.ok) throw new Error(`fal video download ${dlResp.status}`);
const bytes = Buffer.from(await dlResp.arrayBuffer());
const durationPart = effectiveDurationLabel ? ` · ${effectiveDurationLabel}${durationSnappedNote}` : '';
return {
bytes,
providerNote: `fal/${endpoint} · ${aspectRatio}${durationPart} · ${bytes.length} bytes`,
suggestedExt: '.mp4',
};
}
// ---------------------------------------------------------------------------
// Provider: HyperFrames — local HTML→MP4 renderer (heygen-com/hyperframes).
//

View file

@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
}
const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);
const invocation = createCommandInvocation({

View file

@ -0,0 +1,130 @@
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { ProjectLocationPrefs } from './app-config.js';
import { expandHomePrefix } from './home-expansion.js';
import { isSafeId } from './projects.js';
export const BUILT_IN_PROJECT_LOCATION_ID = 'default';
export const PROJECT_MANIFEST_RELATIVE_PATH = path.join('.open-design', 'project.json');
export interface ProjectLocation extends ProjectLocationPrefs {
builtIn?: boolean;
}
export interface ProjectManifest {
schemaVersion: 1;
id: string;
name: string;
createdAt: number;
updatedAt: number;
skillId?: string | null;
designSystemId?: string | null;
}
export function builtInProjectLocation(projectsDir: string): ProjectLocation {
return {
id: BUILT_IN_PROJECT_LOCATION_ID,
name: 'Open Design projects',
path: projectsDir,
builtIn: true,
};
}
export function allProjectLocations(projectsDir: string, external: ProjectLocationPrefs[] | undefined): ProjectLocation[] {
return [builtInProjectLocation(projectsDir), ...(external ?? [])];
}
export function locationProjectDir(location: ProjectLocation, projectId: string): string {
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(location.path, projectId);
}
function assertInsideLocation(locationRoot: string, projectDir: string): void {
const relative = path.relative(locationRoot, projectDir);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('project directory escapes project location');
}
}
export async function createLocationProjectDir(location: ProjectLocation, projectId: string): Promise<string> {
const root = await realpath(location.path);
const target = locationProjectDir({ ...location, path: root }, projectId);
await mkdir(target, { recursive: false });
const info = await lstat(target);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
const canonical = await realpath(target);
assertInsideLocation(root, canonical);
return canonical;
}
export async function canonicalLocationChildDir(location: ProjectLocation, childName: string): Promise<string> {
const root = await realpath(location.path);
if (!isSafeId(childName)) throw new Error('invalid project directory name');
const target = path.join(root, childName);
const info = await lstat(target);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
const canonical = await realpath(target);
assertInsideLocation(root, canonical);
return canonical;
}
export function manifestPath(projectDir: string): string {
return path.join(projectDir, PROJECT_MANIFEST_RELATIVE_PATH);
}
export async function ensureProjectLocation(locationPath: string): Promise<string> {
const expanded = expandHomePrefix(locationPath.trim());
if (!path.isAbsolute(expanded)) throw new Error(`project location must be an absolute path: ${locationPath}`);
await mkdir(expanded, { recursive: true });
return realpath(expanded);
}
export async function writeProjectManifest(projectDir: string, manifest: ProjectManifest): Promise<void> {
const file = manifestPath(projectDir);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(manifest, null, 2), 'utf8');
}
export async function readProjectManifest(projectDir: string): Promise<ProjectManifest | null> {
try {
const raw = await readFile(manifestPath(projectDir), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const obj = parsed as Record<string, unknown>;
if (obj.schemaVersion !== 1) return null;
if (typeof obj.id !== 'string' || !isSafeId(obj.id)) return null;
if (typeof obj.name !== 'string' || !obj.name.trim()) return null;
const createdAt = typeof obj.createdAt === 'number' && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now();
const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : createdAt;
return {
schemaVersion: 1,
id: obj.id,
name: obj.name.trim(),
createdAt,
updatedAt,
skillId: typeof obj.skillId === 'string' ? obj.skillId : null,
designSystemId: typeof obj.designSystemId === 'string' ? obj.designSystemId : null,
};
} catch (err: unknown) {
const e = err as { code?: string; name?: string };
if (e.code === 'ENOENT' || e.name === 'SyntaxError') return null;
throw err;
}
}
export async function scanProjectLocation(location: ProjectLocation): Promise<Array<{ dir: string; manifest: ProjectManifest }>> {
const entries = await readdir(location.path, { withFileTypes: true });
const found: Array<{ dir: string; manifest: ProjectManifest }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
let dir: string;
try {
dir = await canonicalLocationChildDir(location, entry.name);
} catch {
continue;
}
const manifest = await readProjectManifest(dir);
if (manifest) found.push({ dir, manifest });
}
return found;
}

View file

@ -0,0 +1,23 @@
import path from 'node:path';
export function resolveProjectRoot(moduleDir: string): string {
const base = path.basename(moduleDir);
const daemonDir =
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
return path.resolve(daemonDir, '../..');
}
export function resolveProjectRootFromNestedModule(moduleDir: string): string {
let current = path.resolve(moduleDir);
while (true) {
const base = path.basename(current);
if (base === 'dist' || base === 'src') {
return resolveProjectRoot(current);
}
const parent = path.dirname(current);
if (parent === current) {
return resolveProjectRoot(moduleDir);
}
current = parent;
}
}

View file

@ -1,4 +1,6 @@
import type { Express } from 'express';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import {
defaultScenarioPluginIdForProjectMetadata,
type PluginManifest,
@ -17,9 +19,37 @@ import {
import { connectorService } from './connectors/service.js';
import type { RouteDeps } from './server-context.js';
import { listSkills } from './skills.js';
import { isSafeId } from './projects.js';
import {
BUILT_IN_PROJECT_LOCATION_ID,
allProjectLocations,
createLocationProjectDir,
ensureProjectLocation,
scanProjectLocation,
writeProjectManifest,
} from './project-locations.js';
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
function projectDetailResolvedDir(
projectsRoot: string,
project: any,
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const baseDir = typeof project?.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: null;
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
(function(){
@ -125,6 +155,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
const { readAppConfig, writeAppConfig } = ctx.appConfig;
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
@ -182,8 +213,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return Array.from(byTaskKind.values());
}
app.get('/api/projects', (_req, res) => {
async function configuredProjectLocations() {
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const valid = all[0] ? [all[0]] : [];
for (const location of all.slice(1)) {
const validated = validateLinkedDirs([location.path]);
if (validated.error) continue;
const canonical = validated.dirs[0];
if (!canonical) continue;
if (locationOverlapsDaemonData(canonical)) continue;
valid.push({ ...location, path: canonical });
}
return valid;
}
function locationOverlapsDaemonData(locationPath: string): boolean {
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
const projectsDir = path.join(runtimeDir, 'projects');
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
const relativeToProjects = pathRelative(projectsDir, locationPath);
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
}
function pathRelative(from: string, to: string): string {
return path.relative(from, to);
}
function isInsideOrSame(relative: string): boolean {
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
const metadata = project?.metadata;
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
const relative = path.relative(location.path, metadata.baseDir);
return isInsideOrSame(relative) && relative !== '';
}
function isProjectLocationProject(project: any): boolean {
const metadata = project?.metadata;
return metadata?.importedFrom === 'project-location'
|| typeof metadata?.projectLocationId === 'string';
}
function projectVisibleForLocations(
project: any,
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
): boolean {
if (!isProjectLocationProject(project)) return true;
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
}
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
return explicitProjectLocationId.trim();
}
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
? config.defaultProjectLocationId.trim()
: '';
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
return BUILT_IN_PROJECT_LOCATION_ID;
}
const locations = await configuredProjectLocations();
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
? configuredDefault
: BUILT_IN_PROJECT_LOCATION_ID;
}
function unregisterProjectsForRemovedLocations(
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
nextLocations: Array<{ id?: string; path: string }>,
): string[] {
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
const nextPaths = new Set(nextLocations.map((location) => location.path));
const removed = previousLocations.filter(
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
);
if (removed.length === 0) return [];
return listProjects(db)
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
.map((project: any) => project.id);
}
app.get('/api/project-locations', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations };
res.json(body);
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.put('/api/project-locations', async (req, res) => {
try {
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
const previousLocations = await configuredProjectLocations();
const prepared = [];
for (const loc of requested) {
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
const canonicalPath = await ensureProjectLocation(loc.path);
const validated = validateLinkedDirs([canonicalPath]);
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
if (locationOverlapsDaemonData(canonicalPath)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
}
prepared.push({
id: typeof loc.id === 'string' ? loc.id : undefined,
name: typeof loc.name === 'string' ? loc.name : undefined,
path: canonicalPath,
});
}
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations, removedProjectIds };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.post('/api/project-locations/scan', async (_req, res) => {
try {
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
const imported = [];
const existing: string[] = [];
const skipped: Array<{ path: string; reason: string }> = [];
let scanned = 0;
const now = Date.now();
for (const location of locations) {
let found;
try {
found = await scanProjectLocation(location);
} catch (err: any) {
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
continue;
}
scanned += found.length;
for (const entry of found) {
const { manifest } = entry;
if (getProject(db, manifest.id)) {
existing.push(manifest.id);
continue;
}
try {
const project = insertProject(db, {
id: manifest.id,
name: manifest.name,
skillId: manifest.skillId ?? null,
designSystemId: manifest.designSystemId ?? null,
pendingPrompt: null,
metadata: {
kind: 'prototype',
baseDir: entry.dir,
importedFrom: 'project-location',
projectLocationId: location.id,
},
customInstructions: null,
createdAt: manifest.createdAt,
updatedAt: manifest.updatedAt,
});
insertConversation(db, {
id: randomId(),
projectId: manifest.id,
title: null,
createdAt: now,
updatedAt: now,
});
if (project) imported.push(project);
} catch (err: any) {
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
}
}
}
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
const body = { scanned, imported, existing, skipped };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
const latestRunStatuses = listLatestProjectRunStatuses(db);
const awaitingInputProjects = listProjectsAwaitingInput(db);
const activeRunStatuses = new Map();
@ -204,7 +426,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
/** @type {import('@open-design/contracts').ProjectsResponse} */
const body = {
projects: listProjects(db).map((project: any) => ({
projects: listProjects(db)
.filter((project: any) => projectVisibleForLocations(project, locations))
.map((project: any) => ({
...project,
status: composeProjectDisplayStatus(
activeRunStatuses.get(project.id) ??
@ -230,9 +454,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
app.post('/api/projects', async (req, res) => {
try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
if (typeof id !== 'string' || !isSafeId(id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof name !== 'string' || !name.trim()) {
@ -286,11 +510,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
}
const normalizedSkillId = skillValidation.id;
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
let externalProjectDir: string | null = null;
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
if (!location || location.builtIn) {
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
}
if (getProject(db, id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
}
externalProjectDir = await createLocationProjectDir(location, id);
}
const projectMetadata =
metadata && typeof metadata === 'object'
? {
...metadata,
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
@ -299,10 +542,39 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
: {}),
}
: skipDiscoveryBrief === true
? { skipDiscoveryBrief: true }
? {
skipDiscoveryBrief: true,
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
}
: externalProjectDir
? {
kind: 'prototype',
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: null;
const now = Date.now();
const project = insertProject(db, {
let project;
try {
if (externalProjectDir) {
await writeProjectManifest(externalProjectDir, {
schemaVersion: 1,
id,
name: name.trim(),
createdAt: now,
updatedAt: now,
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
});
}
project = insertProject(db, {
id,
name: name.trim(),
skillId: normalizedSkillId,
@ -316,6 +588,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now,
updatedAt: now,
});
} catch (err) {
if (externalProjectDir) {
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
}
throw err;
}
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
insertConversation(db, {
@ -325,7 +603,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now,
updatedAt: now,
});
const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true
@ -378,7 +655,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
) {
const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
await ensureProject(PROJECTS_DIR, id);
await ensureProject(PROJECTS_DIR, id, projectMetadata);
for (const f of tpl.files) {
if (
!f ||
@ -393,6 +670,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
id,
f.name,
Buffer.from(f.content, 'utf8'),
{},
projectMetadata,
);
} catch {
// Skip individual file failures — the template snapshot is
@ -415,11 +694,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
});
app.get('/api/projects/:id', (req, res) => {
app.get('/api/projects/:id', async (req, res) => {
const project = getProject(db, req.params.id);
if (!project)
const locations = await configuredProjectLocations();
if (!project || !projectVisibleForLocations(project, locations))
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project, resolvedDir };
res.json(body);
@ -464,6 +744,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
...(existingMeta.importedFrom === 'folder'
? { importedFrom: 'folder' }
: {}),
...(existingMeta.importedFrom === 'project-location'
? { importedFrom: 'project-location' }
: {}),
...(typeof existingMeta.projectLocationId === 'string'
? { projectLocationId: existingMeta.projectLocationId }
: {}),
...(existingMeta.fromTrustedPicker === true
? { fromTrustedPicker: true as const }
: {}),

View file

@ -26,6 +26,7 @@ import {
isPublicationGuardedArtifactKind,
} from './artifact-publication-guard.js';
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
@ -40,13 +41,42 @@ export function projectDir(projectsRoot, projectId) {
return path.join(projectsRoot, projectId);
}
export class SandboxImportedProjectError extends Error {
code = 'SANDBOX_IMPORTED_PROJECT_UNAVAILABLE';
constructor() {
super(
'Imported-folder projects are not available in OD_SANDBOX_MODE until their files are mirrored into the managed project directory.',
);
this.name = 'SandboxImportedProjectError';
}
}
function hasExternalProjectRoot(metadata?) {
if (typeof metadata?.baseDir !== 'string') return false;
return path.isAbsolute(path.normalize(metadata.baseDir));
}
export function assertSandboxProjectRootAvailable(metadata?) {
if (isSandboxModeEnabled(process.env) && hasExternalProjectRoot(metadata)) {
throw new SandboxImportedProjectError();
}
}
function usesExternalProjectRoot(metadata?) {
if (isSandboxModeEnabled(process.env)) return false;
return hasExternalProjectRoot(metadata);
}
// Returns the folder a project's files live in. For git-linked projects
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
// back to the standard computed path under projectsRoot.
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
if (typeof metadata?.baseDir === 'string') {
const p = path.normalize(metadata.baseDir);
if (path.isAbsolute(p)) return p;
export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
if (!opts.allowUnavailableSandboxImportedProject) {
assertSandboxProjectRootAvailable(metadata);
}
if (usesExternalProjectRoot(metadata)) {
return path.normalize(metadata.baseDir);
}
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId);
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
export async function ensureProject(projectsRoot, projectId, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
// Git-linked folders already exist; skip mkdir to avoid side-effects.
if (typeof metadata?.baseDir !== 'string') {
if (!usesExternalProjectRoot(metadata)) {
await mkdir(dir, { recursive: true });
}
return dir;
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
const out = [];
// Skip build/install dirs for linked folders so node_modules doesn't stall
// the walk on large repos.
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
await collectFiles(dir, '', out, skipDirs, dir);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);

View file

@ -243,16 +243,18 @@ reported that exact condition. One failed dispatcher call is enough to
report the error; do not fan out into alternate execution paths inside
the same turn.
### Long-running renders (Volcengine i2v, hyperframes-html): generate wait loop
### All slow renders: generate wait loop
\`media generate\` no longer blocks for the full render. It dispatches
the task daemon-side and either returns the finished \`{"file":{...}}\`
or returns a successful queued/running handoff with \`{taskId}\`. You then
drive the render to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\` in
a loop each call long-polls the daemon for up to 25s, well below your
shell tool's default 30s timeout. \`media generate\` treats the handoff as
exit \`0\` so the first dispatch does not look like a failed shell call.
The wait subcommand exits with a distinct code per outcome:
Any model whose generation takes longer than ~25s including **fal flux-pro-ultra,
fal Veo, fal Sora, Volcengine i2v, hyperframes-html, and anything else with a
multi-minute pipeline** will not complete within the initial \`media generate\` call.
\`media generate\` dispatches the task daemon-side and polls for up to ~25s. It
always exits 0 either with \`{"file":{...}}\` if the render finished within that
window, or with \`{"taskId":"..."}\` as a handoff signal. You then drive the render
to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\`
in a loop each call long-polls the daemon for up to 120s. The wait subcommand
exits with a distinct code per outcome:
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
@ -262,33 +264,43 @@ The wait subcommand exits with a distinct code per outcome:
off (\`--since\` skips already-seen progress lines so you don't see the
same chatter twice).
The pattern in your shell tool:
The pattern in your shell tool (uses python3 to parse JSON do NOT use jq, it
may not be installed):
\`\`\`bash
out=$("$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model --image )
ec=$?
if [ "$ec" -ne 0 ]; then
echo "$out" >&2; exit "$ec"
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model flux-pro-ultra --prompt "…")
ec=\$?
if [ "\$ec" -ne 0 ]; then
echo "\$out" >&2; exit "\$ec"
fi
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty')
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0')
while [ -n "$task_id" ]; do
out=$("$OD_NODE_BIN" "$OD_BIN" media wait "$task_id" --since "$since")
ec=$?
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since")
if [ "$ec" -eq 0 ]; then
last=\$(printf '%s\\n' "\$out" | tail -1)
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
since="\${since:-0}"
while [ -n "\$task_id" ]; do
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
ec=\$?
last=\$(printf '%s\\n' "\$out" | tail -1)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
since="\${since:-0}"
if [ "\$ec" -eq 0 ]; then
task_id=""
elif [ "$ec" -ne 2 ]; then
echo "$out" >&2; exit "$ec"
elif [ "\$ec" -ne 2 ]; then
echo "\$out" >&2; exit "\$ec"
fi
done
# At this point ec is 0 (done). Final result on the last stdout line of \`out\`.
# At this point ec is 0 (done) or 5 (failed). Final result on the last stdout line of \$out.
printf '%s\\n' "\$last"
\`\`\`
Each \`generate\` and \`wait\` call lasts at most ~25s, so the agent
shell tool's default ~30s cap never fires. Progress lines stream to
stderr as they arrive, so the user sees live status in chat throughout
the loop instead of waiting silently for a single multi-minute call.
Each \`generate\` call lasts at most ~25s and each \`wait\` call at most ~120s,
both well within your shell tool's timeout. Progress lines stream to stderr as
they arrive, so the user sees live status in chat throughout the loop instead of
waiting silently for a single multi-minute call.
**Always write your shell invocation as the full generate+wait loop above**, even
for image models. \`flux-pro-ultra\` routinely takes 60180s; \`sora-2\` and
\`veo-3-fal\` take longer. In the wait loop, exit 2 means "keep polling, not an error."
A note on \`fetch failed\` to \`127.0.0.1\`. The OD daemon runs on
loopback in the same machine that spawned you, so it is essentially
@ -318,10 +330,19 @@ showed it crashed).
- **audio · speech**: ${AUDIO_SPEECH_IDS}
- **audio · sfx**: ${AUDIO_SFX_IDS}
If the user requests a model that is not in this list, surface a warning
in your reply and either (a) ask them to pick a registered ID or (b)
proceed with the project metadata's default model and explain the
substitution. Do not silently fall back.
If the user requests a model that is not in this list **and** the ID does
not start with \`fal-ai/\`, surface a warning in your reply and either
(a) ask them to pick a registered ID or (b) proceed with the project
metadata's default model and explain the substitution. Do not silently
fall back.
Exception **fal-ai/\* custom paths**: any model ID that begins with
\`fal-ai/\` (e.g. \`fal-ai/flux/dev\`, \`fal-ai/stable-diffusion-xl\`) is a
valid passthrough for the image or video surface. Pass it to
\`"$OD_NODE_BIN" "$OD_BIN" media generate\` as-is via \`--model <id>\`;
the daemon routes it directly to the fal queue without a catalog entry.
Do **not** warn the user or substitute the default when a \`fal-ai/\`
path is given.
### Workflow rules
@ -344,22 +365,47 @@ substitution. Do not silently fall back.
SFX duration is capped at 30 seconds by the provider.
\`language\` enables pronunciation boost for specific languages
(e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin).
2. **One discovery turn before generating.** Even with metadata defaults
present, restate what you're about to make and ask one targeted
question if anything is ambiguous (subject, mood, brand, voice). The
discovery rules from the philosophy layer still apply emit a
question form on turn 1 unless the user's prompt already pins every
variable.
2. **Dispatch immediately when the brief is complete.** For image and video
projects, if the user's prompt specifies the subject, style/mood, and setting,
**dispatch without a discovery question turn**. Do not ask about model or aspect
ratio when reasonable defaults exist use them and start generating.
Default model selection (use these when \`imageModel\`/\`videoModel\` is unknown
or the user asks for "best"):
- **Image, best quality (user says "best", "highest quality", "most realistic")**:
use \`flux-pro-ultra\` — but tell the user it takes 60180s
- **Image, default / no preference stated**: use the project metadata's
\`imageModel\` if set; otherwise use \`gpt-image-2\`
- **Video, best quality**: use project metadata \`videoModel\` if set; otherwise
\`doubao-seedance-2-0-260128\`
Default aspect ratio (use when \`aspectRatio\` is unknown):
- Landscape/outdoor scenes, cinematic, widescreen \`16:9\`
- Portrait, vertical social \`9:16\`
- Product, abstract, square social \`1:1\`
- General default when no cue \`1:1\`
**Skip the discovery question when all of these are true:**
- The subject is described (what to generate)
- The style or mood is implied or stated (realistic, cinematic, illustrated, etc.)
- Any model/aspect gaps can be filled with the defaults above
**Do ask** if the output intent is genuinely ambiguous (e.g. "make something cool"
with no subject), or the user explicitly requests a model/voice the project
metadata doesn't carry.
For \`hyperframes-html\`, the discovery turn is the last turn before
you start authoring. Once the user answers, write the composition
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
immediately do not add a second "plan" or "environment check"
message first, and do not call \`"$OD_NODE_BIN" "$OD_BIN" media generate\` (that path is
intentionally rejected for this model).
3. **Generate by shell, narrate in chat.** When you actually invoke
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call. After
it returns, write a short reply: what was produced, the filename,
and any notes (model substitutions, retries, follow-up suggestions).
3. **Generate by shell, reply in one short message.** When you invoke
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call.
After the command completes, reply with **one brief message** (23 sentences max):
the filename, the model used, and a single follow-up offer ("Want a different
aspect ratio?" / "Try again with more fog?"). Do not write long descriptions,
artistic analyses, or multi-paragraph commentary. Speed matters.
If it fails, quote the real stderr / exit code and stop there.
Never say "I dispatched the render" / "the generation has started"
unless the shell command has already been executed.

View file

@ -222,6 +222,62 @@ export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
// Injected into non-media projects so the agent knows how to dispatch
// media generation if the user asks for it mid-session (e.g. "generate an
// image with fal"). Without this, agents in prototype/deck projects try to
// call provider REST APIs directly and ask the user for keys that the daemon
// already holds in .od/media-config.json.
const MEDIA_DISPATCH_HINT = `
---
## Media generation (if asked)
If the user asks you to generate an image, video, or audio file regardless of which provider or model they mention (fal, Replicate, OpenAI, etc.) use the daemon dispatcher via your **Bash tool**. Do NOT call provider REST APIs directly.
The daemon injects these env vars into your shell (**POSIX bash not PowerShell**):
- \`OD_NODE_BIN\` — absolute path to the Node runtime
- \`OD_BIN\` — absolute path to the OD CLI script
- \`OD_PROJECT_ID\` — the active project id
**Always use the generatewait loop below.** \`media generate\` always exits 0 — either with \`{"file":{...}}\` if done within ~25s, or with \`{"taskId":"..."}\` as a handoff for slow models (flux-pro-ultra ~60180s, veo-3-fal longer). Whenever the output contains a \`taskId\`, keep polling with \`media wait\` until exit 0 (done) or exit 5 (failed).
Use **POSIX \`$VAR\` syntax** — do NOT translate to PowerShell (\`$env:VAR\`, \`&\` operator). Uses \`python3\` for JSON parsing (do NOT use \`jq\`):
\`\`\`bash
# POSIX bash do NOT convert to PowerShell
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate \\
--project "$OD_PROJECT_ID" \\
--surface image \\
--model flux-pro-ultra \\
--prompt "..." \\
--aspect 16:9)
ec=\$?
if [ "\$ec" -ne 0 ]; then echo "\$out" >&2; exit "\$ec"; fi
last=\$(printf '%s\\n' "\$out" | tail -1)
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
since="\${since:-0}"
while [ -n "\$task_id" ]; do
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
ec=\$?
last=\$(printf '%s\\n' "\$out" | tail -1)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
since="\${since:-0}"
if [ "\$ec" -eq 0 ]; then
task_id=""
elif [ "\$ec" -ne 2 ]; then
echo "\$out" >&2; exit "\$ec"
fi
done
printf '%s\\n' "\$last"
\`\`\`
**Never ask the user for an API key.** The daemon reads provider credentials from its config; keys are never passed through the shell. If the provider returns an auth error, tell the user to open Settings AI Providers and confirm the key is configured there.
For the best fal image model use \`--model flux-pro-ultra\`. For video use \`--model veo-3-fal\` or \`--model wan-2.1-t2v\`. Always pass \`--surface\` explicitly (\`image\`, \`video\`, or \`audio\`). Any \`fal-ai/*\` path (e.g. \`fal-ai/flux/schnell\`, \`fal-ai/wan-i2v\`) is also a valid \`--model\` value for image/video — pass it through as-is without substitution.`;
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
---
@ -439,6 +495,21 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
// Skip the HTML-artifact discovery layer for media surfaces (image / video /
// audio). DISCOVERY_AND_PHILOSOPHY is ~3 000 tokens of rules about question
// forms, brand extraction, direction pickers, and HTML artifact checklist —
// none of which apply to media generation. Including it forces the agent to
// parse and override all of those rules before it can start, adding tokens
// and LLM inference time. The MEDIA_GENERATION_CONTRACT (pushed below) is
// the sole workflow authority for these surfaces.
const isMediaSurfaceEarly =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n');
@ -450,9 +521,12 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
if (!isMediaSurfaceEarly) {
parts.push(DISCOVERY_AND_PHILOSOPHY, '\n\n---\n\n');
}
parts.push(
DISCOVERY_AND_PHILOSOPHY,
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
'# Identity and workflow charter (background)\n\n',
BASE_SYSTEM_PROMPT,
);
@ -614,6 +688,11 @@ export function composeSystemPrompt({
|| resolvedExclusiveSurface === 'audio';
if (isMediaSurface) {
parts.push(renderMediaGenerationContract(mediaExecution));
} else {
// Non-media projects (prototype, deck, etc.): inject a lightweight hint
// so the agent uses `od media generate` if the user asks for an image/video
// mid-session, rather than hunting for provider API keys in the environment.
parts.push(MEDIA_DISPATCH_HINT);
}
if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
@ -959,10 +1038,10 @@ function renderMetadataBlock(
}
if (metadata.kind === 'image') {
lines.push(
`- **imageModel**: ${metadata.imageModel ?? '(unknown — ask: which image model to use)'}`,
`- **imageModel**: ${metadata.imageModel ?? 'gpt-image-2 (default — override if the user asks for a specific model or provider)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.imageAspect ?? '(unknown — ask: 1:1, 16:9, 9:16, 4:3, 3:4)'}`,
`- **aspectRatio**: ${metadata.imageAspect ?? '1:1 (default — use 16:9 for landscape/outdoor scenes, 9:16 for portrait/vertical)'}`,
);
if (metadata.imageStyle) {
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);

View file

@ -0,0 +1,185 @@
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
export interface RunToolBundle {
mcpServers: McpServerConfig[];
}
export interface RunToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ExternalMcpSelection {
enabledServers: McpServerConfig[];
persistedTokenServerIds: Set<string>;
}
export type RunToolBundleParseResult =
| { ok: true; bundle: RunToolBundle }
| { ok: false; message: string };
export type RunToolBundleValidationResult =
| { ok: true }
| { ok: false; message: string };
export type RunToolBundleDeliveryTarget =
| 'managed-project'
| 'external-project'
| 'none';
export interface RunToolBundleValidationOptions {
deliveryTarget?: RunToolBundleDeliveryTarget;
}
type RunToolBundleAgent = Pick<
RuntimeAgentDef,
'id' | 'name' | 'externalMcpInjection'
>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function agentLabel(agent: RunToolBundleAgent): string {
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
}
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
if (!isPlainObject(raw)) return { mcpServers: [] };
return {
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
};
}
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
if (!isPlainObject(raw)) {
return { ok: false, message: 'toolBundle must be an object' };
}
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
if (!Array.isArray(raw.mcpServers)) {
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
}
const seen = new Set<string>();
const servers: McpServerConfig[] = [];
for (const [index, entry] of raw.mcpServers.entries()) {
const server = sanitizeMcpServer(entry);
if (!server) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] is invalid`,
};
}
if (seen.has(server.id)) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
};
}
seen.add(server.id);
servers.push(server);
}
return { ok: true, bundle: { mcpServers: servers } };
}
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
return {
mcpServers: servers.map((server) => ({
id: server.id,
...(server.label ? { label: server.label } : {}),
...(server.templateId ? { templateId: server.templateId } : {}),
transport: server.transport,
enabled: server.enabled,
...(server.authMode ? { authMode: server.authMode } : {}),
})),
};
}
export function validateRunToolBundleForAgent(
bundle: RunToolBundle | null | undefined,
agent: RunToolBundleAgent | null | undefined,
options: RunToolBundleValidationOptions = {},
): RunToolBundleValidationResult {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
const enabledServers = servers.filter((server) => server.enabled);
if (enabledServers.length === 0) return { ok: true };
if (!agent) {
return {
ok: false,
message: 'toolBundle requires a supported agentId',
};
}
if (agent.externalMcpInjection === 'claude-mcp-json') {
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
return {
ok: false,
message:
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
'so toolBundle requires a daemon-managed project',
};
}
return { ok: true };
}
if (agent.externalMcpInjection === 'opencode-env-content') {
return { ok: true };
}
if (agent.externalMcpInjection === 'acp-merge') {
const unsupported = servers.findIndex(
(server) => server.enabled && server.transport !== 'stdio',
);
if (unsupported === -1) return { ok: true };
return {
ok: false,
message:
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
};
}
return {
ok: false,
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
};
}
export function resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode,
}: {
persistedServers: McpServerConfig[];
runScopedServers: McpServerConfig[];
sandboxMode: boolean;
}): ExternalMcpSelection {
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
const persistedForRun = sandboxMode ? [] : persistedServers;
const byId = new Map<string, McpServerConfig>();
for (const server of persistedForRun) byId.set(server.id, server);
for (const server of runScopedServers) byId.set(server.id, server);
const persistedTokenServerIds = new Set<string>();
for (const server of persistedForRun) {
if (!server.enabled) continue;
if (runScopedIds.has(server.id)) continue;
persistedTokenServerIds.add(server.id);
}
return {
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
persistedTokenServerIds,
};
}

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
import {
normalizeRunToolBundleForRun,
summarizeRunToolBundle,
} from './run-tool-bundle.js';
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
@ -57,6 +61,7 @@ export function createChatRunService({
pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
status: 'queued',
createdAt: now,
updatedAt: now,
@ -149,6 +154,7 @@ export function createChatRunService({
errorCode: run.errorCode ?? null,
eventsLogPath: run.eventsLogPath ?? null,
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
toolBundle: summarizeRunToolBundle(run.toolBundle),
});
const finish = (run, status, code: number | null = null, signal: string | null = null) => {

View file

@ -151,6 +151,8 @@ async function probe(
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);

View file

@ -1,11 +1,27 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
import { resolveProjectRelativePath } from '../home-expansion.js';
import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
import { resolveProjectRootFromNestedModule } from '../project-root.js';
import {
applySandboxRuntimeEnv,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
type SandboxRuntimeConfig,
} from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
);
// Build the env passed to spawn() for a given agent adapter.
//
@ -38,7 +54,9 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv(
process.platform,
systemProxyEnv,
@ -58,20 +76,52 @@ export function spawnEnvForAgent(
const opencodeBin = resolveAmrOpenCodeExecutable(env);
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
}
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'claude') {
if (!isOpenClaudeExecutable(options.resolvedBin)) {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
return env;
}
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'codex') {
stripUnlessCustomBaseUrl(env, 'OPENAI_BASE_URL', [
'OPENAI_API_KEY',
'CODEX_API_KEY',
]);
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
return env;
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
const base = path
.basename(resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude';
}
function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null {
if (!isSandboxModeEnabled(baseEnv)) return null;
const dataDir = baseEnv.OD_DATA_DIR?.trim();
if (!dataDir) return null;
const resolvedDataDir = resolveProjectRelativePath(
dataDir,
RUNTIME_MODULE_PROJECT_ROOT,
);
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
}
function reapplySandboxRuntimeEnv(
env: NodeJS.ProcessEnv,
sandboxRuntime: SandboxRuntimeConfig | null,
): NodeJS.ProcessEnv {
if (!sandboxRuntime) return env;
return applySandboxRuntimeEnv(env, sandboxRuntime);
}
// Remove `secretKeys` from `env` unless `baseUrlKey` is set to a non-empty

View file

@ -2,10 +2,17 @@ import { accessSync, constants, existsSync, statSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { wellKnownUserToolchainBins } from '@open-design/platform';
import { resolveSandboxRuntimeConfigFromEnv } from '../sandbox-mode.js';
import { expandHomePath } from './paths.js';
import type { RuntimeAgentDef } from './types.js';
const RUNTIME_PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
['amr', 'VELA_BIN'],
['aider', 'AIDER_BIN'],
@ -35,7 +42,12 @@ let cachedToolchainDirs: string[] | null = null;
let cachedToolchainDirsAt = 0;
function userToolchainDirs() {
const homeOverride = process.env.OD_AGENT_HOME;
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
process.env,
RUNTIME_PROJECT_ROOT,
);
const homeOverride =
sandboxRuntime?.roots.agentHomeDir ?? process.env.OD_AGENT_HOME;
const home = homeOverride || homedir();
const now = Date.now();
if (

View file

@ -1,7 +1,13 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import path from 'node:path';
import {
isSandboxModeEnabled,
resolveSandboxRuntimeConfigFromEnv,
sandboxAgentProfilesConfigPath,
} from '../sandbox-mode.js';
import { DEFAULT_MODEL_OPTION, sanitizeCustomModel } from './models.js';
import type {
RuntimeAgentDef,
@ -9,10 +15,44 @@ import type {
RuntimeModelOption,
} from './types.js';
function localAgentProfilesFile(): string {
const RUNTIME_PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../..',
);
function isInsideDir(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
function localAgentProfilesFile(): string | null {
const explicit = process.env.OD_AGENT_PROFILES_CONFIG;
if (typeof explicit === 'string' && explicit.trim()) {
return explicit.trim();
const explicitPath =
typeof explicit === 'string' && explicit.trim()
? path.resolve(explicit.trim())
: null;
if (isSandboxModeEnabled(process.env)) {
if (!process.env.OD_DATA_DIR?.trim()) return null;
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
process.env,
RUNTIME_PROJECT_ROOT,
);
if (!sandboxRuntime?.enabled) return null;
if (
explicitPath &&
isInsideDir(sandboxRuntime.roots.agentHomeDir, explicitPath)
) {
return explicitPath;
}
return sandboxAgentProfilesConfigPath(sandboxRuntime);
}
if (explicitPath) {
return explicitPath;
}
return path.join(homedir(), '.open-design', 'agents.local.json');
}
@ -152,9 +192,11 @@ function createLocalAgentDef(
export function readLocalAgentProfileDefs(
baseDefs: RuntimeAgentDef[],
): RuntimeAgentDef[] {
const profilesFile = localAgentProfilesFile();
if (profilesFile == null) return [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(localAgentProfilesFile(), 'utf8'));
parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
} catch {
return [];
}

View file

@ -0,0 +1,134 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveProjectRelativePath } from './home-expansion.js';
export const SANDBOX_MODE_ENV = 'OD_SANDBOX_MODE';
export interface SandboxRuntimeRoots {
agentHomeDir: string;
cacheDir: string;
configDir: string;
generatedFilesDir: string;
logsDir: string;
mcpConfigDir: string;
pluginStateDir: string;
previewStateDir: string;
skillsCacheDir: string;
tempDir: string;
toolConfigDir: string;
}
export interface SandboxRuntimeConfig {
enabled: boolean;
dataDir: string;
roots: SandboxRuntimeRoots;
}
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSY_VALUES = new Set(['0', 'false', 'no', 'off', '']);
export function isSandboxModeEnabled(
env: Record<string, string | undefined> = process.env,
): boolean {
const raw = env[SANDBOX_MODE_ENV];
if (typeof raw !== 'string') return false;
const value = raw.trim().toLowerCase();
if (TRUTHY_VALUES.has(value)) return true;
if (FALSY_VALUES.has(value)) return false;
throw new Error(
`${SANDBOX_MODE_ENV} must be one of ${Array.from(TRUTHY_VALUES).join(', ')} ` +
`or ${Array.from(FALSY_VALUES).join(', ')}`,
);
}
export function resolveSandboxRuntimeConfig(
enabled: boolean,
dataDir: string,
): SandboxRuntimeConfig {
const sandboxRoot = path.join(dataDir, 'sandbox');
return {
enabled,
dataDir,
roots: {
agentHomeDir: path.join(sandboxRoot, 'agent-home'),
cacheDir: path.join(sandboxRoot, 'cache'),
configDir: path.join(sandboxRoot, 'config'),
generatedFilesDir: path.join(dataDir, 'generated-files'),
logsDir: path.join(dataDir, 'logs'),
mcpConfigDir: dataDir,
pluginStateDir: path.join(dataDir, 'plugins'),
previewStateDir: path.join(dataDir, 'previews'),
skillsCacheDir: path.join(dataDir, 'skills'),
tempDir: path.join(sandboxRoot, 'tmp'),
toolConfigDir: path.join(sandboxRoot, 'tools'),
},
};
}
export function resolveSandboxRuntimeConfigFromEnv(
env: Record<string, string | undefined>,
projectRoot: string,
): SandboxRuntimeConfig | null {
if (!isSandboxModeEnabled(env)) return null;
const rawDataDir = env.OD_DATA_DIR?.trim();
if (!rawDataDir) {
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
}
return resolveSandboxRuntimeConfig(
true,
resolveProjectRelativePath(rawDataDir, projectRoot),
);
}
export function sandboxAgentProfilesConfigPath(
config: SandboxRuntimeConfig,
): string {
return path.join(
config.roots.agentHomeDir,
'.open-design',
'agents.local.json',
);
}
export function ensureSandboxRuntimeDirs(config: SandboxRuntimeConfig): void {
if (!config.enabled) return;
for (const dir of new Set(Object.values(config.roots))) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function applySandboxRuntimeEnv(
baseEnv: NodeJS.ProcessEnv,
config: SandboxRuntimeConfig,
): NodeJS.ProcessEnv {
if (!config.enabled) return baseEnv;
const env: NodeJS.ProcessEnv = { ...baseEnv };
const { roots } = config;
const codexHome = path.join(roots.agentHomeDir, '.codex');
const claudeConfigDir = path.join(roots.configDir, 'claude');
const opencodeHome = path.join(roots.agentHomeDir, '.opencode');
const npmUserConfig = path.join(roots.toolConfigDir, 'npmrc');
env[SANDBOX_MODE_ENV] = '1';
env.OD_DATA_DIR = config.dataDir;
env.OD_AGENT_HOME = roots.agentHomeDir;
env.HOME = roots.agentHomeDir;
env.USERPROFILE = roots.agentHomeDir;
env.XDG_CONFIG_HOME = roots.configDir;
env.XDG_CACHE_HOME = roots.cacheDir;
env.XDG_DATA_HOME = path.join(roots.configDir, 'data');
env.XDG_STATE_HOME = path.join(roots.configDir, 'state');
env.TMPDIR = roots.tempDir;
env.TEMP = roots.tempDir;
env.TMP = roots.tempDir;
env.CODEX_HOME = codexHome;
env.CLAUDE_CONFIG_DIR = claudeConfigDir;
env.OPENCODE_TEST_HOME = opencodeHome;
env.OD_AGENT_PROFILES_CONFIG = sandboxAgentProfilesConfigPath(config);
env.NPM_CONFIG_USERCONFIG = npmUserConfig;
env.npm_config_userconfig = npmUserConfig;
return env;
}

View file

@ -25,7 +25,10 @@ import {
shouldRenderCodexImagegenOverride,
} from './prompts/system.js';
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
import { resolveProjectRoot } from './project-root.js';
import { userFacingAgentLabel } from './user-facing-agent-label.js';
export { resolveProjectRoot };
import { createCommandInvocation } from '@open-design/platform';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import {
@ -90,6 +93,12 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import {
applySandboxRuntimeEnv,
ensureSandboxRuntimeDirs,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
} from './sandbox-mode.js';
import {
createUserDesignSystem,
deleteUserDesignSystem,
@ -252,6 +261,7 @@ import {
type ObservabilityEventRequest,
} from '@open-design/contracts/analytics';
import {
mergeNoProxyWithLoopbackDefaults,
redactSecrets,
testAgentConnection,
testProviderConnection,
@ -306,6 +316,11 @@ import {
readMcpConfig,
writeMcpConfig,
} from './mcp-config.js';
import {
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
validateRunToolBundleForAgent,
} from './run-tool-bundle.js';
import {
beginAuth,
exchangeCodeForToken,
@ -335,6 +350,7 @@ import {
buildBatchArchive,
decodeMultipartFilename,
deleteProjectFile,
assertSandboxProjectRootAvailable,
detectEntryFile,
ensureProject,
isSafeId,
@ -346,6 +362,7 @@ import {
renameProjectFile,
removeProjectDir,
resolveProjectDir,
SandboxImportedProjectError,
sanitizeName,
searchProjectFiles,
resolveProjectDir,
@ -476,13 +493,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH';
export function resolveProjectRoot(moduleDir: string): string {
const base = path.basename(moduleDir);
const daemonDir =
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
return path.resolve(daemonDir, '../..');
}
function cleanOptionalPath(value: string | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0
? path.resolve(value)
@ -1328,8 +1338,14 @@ function createMarketplaceFetcher(seedId, bundledMarketplaceEntries) {
};
}
export function resolveDataDir(raw, projectRoot) {
if (!raw) return path.join(projectRoot, '.od');
export function resolveDataDir(raw, projectRoot, options = {}) {
const value = raw?.trim();
if (!value) {
if (options.requireExplicit) {
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
}
return path.join(projectRoot, '.od');
}
// expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and
// OD_MEDIA_CONFIG_DIR can never split state under a $HOME-style value.
// Some launchers (systemd unit files, NixOS modules, certain Docker
@ -1338,7 +1354,7 @@ export function resolveDataDir(raw, projectRoot) {
// expandHomePrefix turns those (and the ~ shorthand, with both / and \
// separators) into os.homedir() before path.resolve runs so launch
// surfaces stay consistent.
const resolved = resolveProjectRelativePath(raw, projectRoot);
const resolved = resolveProjectRelativePath(value, projectRoot);
try {
fs.mkdirSync(resolved, { recursive: true });
fs.accessSync(resolved, fs.constants.W_OK);
@ -1364,7 +1380,12 @@ export function resolveDataDir(raw, projectRoot) {
}
return resolved;
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
const SANDBOX_MODE_ENABLED = isSandboxModeEnabled(process.env);
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT, {
requireExplicit: SANDBOX_MODE_ENABLED,
});
const SANDBOX_RUNTIME = resolveSandboxRuntimeConfig(SANDBOX_MODE_ENABLED, RUNTIME_DATA_DIR);
ensureSandboxRuntimeDirs(SANDBOX_RUNTIME);
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
// that compare it against a user-supplied realpath() result. On macOS, /var
@ -1623,16 +1644,26 @@ export function createAgentRuntimeEnv(
toolTokenGrant: { token?: string } | null = null,
nodeBin: string = process.execPath,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
const env: NodeJS.ProcessEnv = applySandboxRuntimeEnv(
{
...baseEnv,
OD_DATA_DIR: RUNTIME_DATA_DIR,
OD_DAEMON_URL: daemonUrl,
OD_NODE_BIN: nodeBin,
};
},
SANDBOX_RUNTIME,
);
const sidecarIpcPath = baseEnv[SIDECAR_ENV.IPC_PATH];
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
if (SANDBOX_RUNTIME.enabled) {
const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy);
if (noProxy) {
env.NO_PROXY = noProxy;
if (process.platform !== 'win32') env.no_proxy = noProxy;
}
}
// Ensure the node binary directory is on PATH so agent sub-processes —
// in particular npm .cmd shims on Windows that run `"node" script.js` —
@ -3842,10 +3873,18 @@ export async function startServer({
// Active only when OD_API_TOKEN is set. Loopback origins skip the
// check (the desktop UI / local CLI never carry a bearer); every
// other request must present `Authorization: Bearer <token>` with a
// value matching `OD_API_TOKEN`. Health / version / status remain
// open so monitoring probes don't need the token.
// value matching `OD_API_TOKEN`. Health / readiness / version remain
// open so monitoring probes don't need the token. Rich daemon status
// stays authenticated because it includes local runtime paths.
if (apiToken.length > 0) {
const openProbePaths = new Set(['/api/health', '/api/version', '/api/daemon/status']);
const openProbePaths = new Set([
'/health',
'/api/health',
'/ready',
'/api/ready',
'/version',
'/api/version',
]);
app.use('/api', (req, res, next) => {
if (openProbePaths.has(req.path)) return next();
// Loopback short-circuit. We ignore the proxied X-Forwarded-For
@ -4356,6 +4395,16 @@ export async function startServer({
res.json({ ok: true, version: versionInfo.version });
});
app.get('/api/ready', async (_req, res) => {
const versionInfo = await readCurrentAppVersionInfo();
const ready = !daemonShuttingDown;
res.status(ready ? 200 : 503).json({
ok: ready,
ready,
version: versionInfo.version,
});
});
app.get('/api/version', async (_req, res) => {
const version = await readCurrentAppVersionInfo();
res.json({ version });
@ -4409,6 +4458,10 @@ export async function startServer({
port: Number(process.env.OD_PORT ?? 7456),
dataDir: RUNTIME_DATA_DIR,
mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null,
sandboxMode: SANDBOX_RUNTIME.enabled,
sandbox: SANDBOX_RUNTIME.enabled
? { enabled: true, roots: SANDBOX_RUNTIME.roots }
: { enabled: false },
pid: process.pid,
shuttingDown: daemonShuttingDown,
installedPlugins: (() => {
@ -5680,6 +5733,7 @@ export async function startServer({
events: projectEventDeps,
ids: idDeps,
telemetry: { reportFinalizedMessage },
appConfig: appConfigDeps,
validation: validationDeps,
});
registerImportRoutes(app, {
@ -10727,22 +10781,21 @@ export async function startServer({
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly
// so the agent writes back to the user's original source tree.
// Project directory resolution lives in projects.ts so sandbox mode can
// consistently reject imported-folder metadata that has no managed copy.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
try {
const chatProject = getProject(db, projectId);
const chatMeta = chatProject?.metadata;
if (chatMeta?.baseDir) {
cwd = path.normalize(chatMeta.baseDir);
assertSandboxProjectRootAvailable(chatMeta);
cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
} else {
cwd = await ensureProject(PROJECTS_DIR, projectId);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return design.runs.fail(run, 'BAD_REQUEST', err.message);
}
} catch {
cwd = null;
}
}
@ -10854,6 +10907,7 @@ export async function startServer({
// values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading.
let externalMcpConfig = { servers: [] };
if (!SANDBOX_RUNTIME.enabled) {
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
@ -10862,12 +10916,24 @@ export async function startServer({
err && err.message ? err.message : err,
);
}
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
}
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
? run.toolBundle.mcpServers
: [];
const {
enabledServers: enabledExternalMcp,
persistedTokenServerIds,
} = resolveExternalMcpServersForRun({
persistedServers: externalMcpConfig.servers,
runScopedServers: runScopedMcpServers,
sandboxMode: SANDBOX_RUNTIME.enabled,
});
const oauthTokensForSpawn = {};
if (persistedTokenServerIds.size > 0) {
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
if (!persistedTokenServerIds.has(serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
@ -10906,6 +10972,7 @@ export async function startServer({
err && err.message ? err.message : err,
);
}
}
const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
.map((s) => ({ id: s.id, label: s.label }));
@ -11268,6 +11335,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
),
agentLaunch,
)
@ -11650,6 +11719,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
);
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12603,6 +12674,7 @@ export async function startServer({
stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail,
env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
});
// A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code
@ -12948,14 +13020,33 @@ export async function startServer({
};
});
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
return 'none';
}
try {
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
allowUnavailableSandboxImportedProject: true,
});
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
} catch {
return 'none';
}
}
app.post('/api/runs', async (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
// before the run is created. The resolver returns null when the body
// does not mention a plugin (legacy runs unchanged), an error envelope
@ -12971,7 +13062,7 @@ export async function startServer({
// bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
let registryView;
try {
registryView = await loadPluginRegistryView();
@ -12979,26 +13070,26 @@ export async function startServer({
return res.status(500).json({ error: String(err) });
}
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let runResolveBody = req.body;
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
let runResolveBody = requestBody;
if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId);
const projectRow = getProject(db, requestBody.projectId);
const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
}
}
}
const resolved = resolvePluginSnapshot({
db,
body: runResolveBody,
projectId: req.body.projectId,
conversationId: typeof req.body.conversationId === 'string'
? req.body.conversationId
projectId: requestBody.projectId,
conversationId: typeof requestBody.conversationId === 'string'
? requestBody.conversationId
: null,
registry: registryView,
connectorProbe: buildConnectorProbe(connectorService),
@ -13006,7 +13097,7 @@ export async function startServer({
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
@ -13015,7 +13106,11 @@ export async function startServer({
resolvedSnapshot = resolved;
}
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
if (resolvedSnapshot?.ok) {
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
@ -13027,6 +13122,53 @@ export async function startServer({
if (renderedQuery.length > 0) meta.message = renderedQuery;
}
}
let runProject = null;
if (typeof meta.projectId === 'string' && meta.projectId) {
try {
runProject = getProject(db, meta.projectId);
assertSandboxProjectRootAvailable(runProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
// MCP / SDK callers may omit agentId. Resolve it before any run-create
// side effects so unsupported run-scoped tool bundles can fail cleanly.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
meta.projectId,
runProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
// MCP / SDK callers POST /api/runs with just a projectId — no
// conversationId, no pre-created assistantMessageId — because they
// don't know about OD's chat-row lifecycle. The web flow
@ -13051,7 +13193,18 @@ export async function startServer({
) {
try {
const convs = listConversations(db, meta.projectId);
const defaultConv = Array.isArray(convs) && convs.length > 0 ? convs[0] : null;
// listConversations is ordered for the UI by recent activity; this
// fallback must bind to the seeded default conversation instead.
const defaultConv = Array.isArray(convs) && convs.length > 0
? [...convs].sort((a, b) => {
const aCreated = Number(a?.createdAt);
const bCreated = Number(b?.createdAt);
if (Number.isFinite(aCreated) && Number.isFinite(bCreated) && aCreated !== bCreated) {
return aCreated - bCreated;
}
return String(a?.id ?? '').localeCompare(String(b?.id ?? ''));
})[0]
: null;
if (defaultConv && typeof defaultConv.id === 'string' && defaultConv.id) {
meta.conversationId = defaultConv.id;
if (typeof meta.assistantMessageId !== 'string' || !meta.assistantMessageId) {
@ -13075,27 +13228,6 @@ export async function startServer({
console.warn('[runs] mcp conversation fallback failed', err);
}
}
// MCP / SDK callers may omit agentId. Resolve it from the saved
// app-config agent (the user's configured default) or the first
// available CLI so the run does not immediately fail with
// "unknown agent: undefined" inside startChatRun.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
if (cfgAgent) {
meta.agentId = cfgAgent;
} else {
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const run = design.runs.create(meta);
try {
pinAssistantMessageOnRunCreate(db, run);
@ -13510,11 +13642,45 @@ export async function startServer({
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
let chatProject = null;
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
try {
chatProject = getProject(db, requestBody.projectId);
assertSandboxProjectRootAvailable(chatProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
requestBody.projectId,
chatProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
const run = design.runs.create(meta);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(meta, run));
@ -13591,6 +13757,7 @@ export async function startServer({
if (routine.target.mode === 'reuse') {
const project = getProject(db, routine.target.projectId);
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
assertSandboxProjectRootAvailable(project.metadata);
projectId = project.id;
projectName = project.name;
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;

View file

@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR);
});
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
const env = createAgentRuntimeEnv(
{ PATH: '/bin', HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: '' },
'http://127.0.0.1:7456',
{ token: 'fresh-token' },
'/opt/open-design/bin/node',
);
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9');
expect(env.NO_PROXY).toBe('');
expect(env.no_proxy).toBeUndefined();
});
it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => {
const env = createAgentRuntimeEnv(
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },

View file

@ -5,7 +5,7 @@
// OD_API_TOKEN is set.
// 2. When OD_API_TOKEN is set, every /api/* request from a non-loopback
// peer must carry `Authorization: Bearer <OD_API_TOKEN>`. The
// health/version/status probes stay open for monitoring.
// health/readiness/version probes stay open for monitoring.
//
// Tests force the bearer-required code path by stamping the env vars
// before startServer. The daemon listens on 127.0.0.1 throughout (so
@ -77,8 +77,8 @@ describe('bearer middleware', () => {
expect(resp.status).toBe(200);
});
it('keeps health / version / daemon-status open without a bearer', async () => {
for (const path of ['/api/health', '/api/version', '/api/daemon/status']) {
it('keeps health / readiness / version probes open without a bearer', async () => {
for (const path of ['/api/health', '/api/ready', '/api/version']) {
const resp = await fetch(`${baseUrl}${path}`);
expect(resp.status).toBe(200);
}

View file

@ -1,6 +1,6 @@
import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import express from 'express';
import {
@ -623,6 +623,187 @@ describe('app-config telemetry prefs', () => {
});
});
describe('app-config projectLocations', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists valid projectLocations and reads them back', async () => {
const locs = [
{ id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' },
{ id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual(locs);
});
it('normalizes ~/ paths via expandHomePrefix', async () => {
const home = homedir();
const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.join(home, 'od-projects'));
expect(path.isAbsolute(first.path)).toBe(true);
});
it('drops relative paths that cannot be resolved to absolute', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad Rel', path: './relative/path' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('drops entries without a string path', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'no-path', name: 'No Path' },
];
await writeAppConfig(dataDir, { projectLocations: locs as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('deduplicates paths (case-sensitive on unix)', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-same' },
{ id: 'second', name: 'Second', path: '/tmp/od-same' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
// Single canonical entry, second deduplicated
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-same'));
});
it('deduplicates by resolved path after normalization', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' },
{ id: 'second', name: 'Second', path: '/tmp/od-dup' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-dup'));
});
it('rejects reserved id "default" and falls back to auto-generated id', async () => {
const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
// The stored id must NOT be 'default'
const first = cfg.projectLocations![0]!;
expect(first.id).not.toBe('default');
// The auto-generated id follows the hash-backed base64url pattern
expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/);
expect(first.path).toBe(path.normalize('/tmp/od-hijack'));
});
it('generates distinct ids for sibling paths with long shared prefixes', async () => {
const locs = [
{ path: '/tmp/open-design-project-locations/shared-prefix-one' },
{ path: '/tmp/open-design-project-locations/shared-prefix-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((location) => location.id);
expect(new Set(ids).size).toBe(2);
expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true);
});
it('persists a defaultProjectLocationId preference', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }],
defaultProjectLocationId: 'external-default',
});
const cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('external-default');
});
it('normalizes invalid defaultProjectLocationId values', async () => {
await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' });
let cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('default');
await writeAppConfig(dataDir, { defaultProjectLocationId: null });
cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBeNull();
});
it('drops invalid scalar projectLocations (not an array)', async () => {
await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
});
it('clears projectLocations when empty array is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: [] });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual([]);
expect(cfg.onboardingCompleted).toBe(true);
});
it('clears projectLocations when null is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: null as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('validates projectLocations on read (filters corrupted stored data)', async () => {
// Write raw JSON with invalid entries
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
projectLocations: [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad', path: 'relative' },
{ id: 'no-path', name: 'No Path' },
'not-an-object',
null,
{ id: 'good2', name: 'Dup Path', path: '/tmp/od-good' },
{ id: 'default', name: 'Reserved', path: '/tmp/od-reserved' },
],
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((l) => l.id);
expect(ids).not.toContain('default');
expect(ids).not.toContain('bad-relative');
expect(ids).not.toContain('no-path');
});
});
describe('app-config origin guard', () => {
let server: http.Server;
let port: number;

View file

@ -86,6 +86,42 @@ async function withFakeAgent<T>(
}
}
async function withOnlyFakeAgent<T>(
binName: string,
script: string,
run: () => Promise<T>,
): Promise<T> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
const oldClaudeBin = process.env.CLAUDE_BIN;
try {
if (process.platform === 'win32') {
const runner = path.join(dir, `${binName}-test-runner.cjs`);
await fsp.writeFile(runner, script);
await fsp.writeFile(
path.join(dir, `${binName}.cmd`),
`@echo off\r\nnode "${runner}" %*\r\n`,
);
} else {
const bin = path.join(dir, binName);
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
delete process.env.CLAUDE_BIN;
return await run();
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
await fsp.rm(dir, { recursive: true, force: true });
}
}
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run);
}
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run);
}
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
return withOnlyFakeAgent('openclaude', script, run);
}
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run);
}
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
);
});
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
const previousKey = process.env.ANTHROPIC_API_KEY;
try {
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
await withOnlyFakeOpenClaude(
`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
}));
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
JSON.parse(input.trim());
console.log(JSON.stringify({
type: 'assistant',
message: {
id: 'msg_1',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
},
}));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
`,
async () => {
const result = await testAgentConnection({ agentId: 'claude' });
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Claude Code',
});
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
);
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
},
);
} finally {
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = previousKey;
await fsp.rm(envFile, { force: true });
}
});
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -38,6 +38,8 @@ describe('GET /api/daemon/status', () => {
version: unknown;
bindHost: unknown;
port: unknown;
sandboxMode: boolean;
sandbox: { enabled: boolean };
pid: unknown;
installedPlugins: unknown;
shuttingDown: boolean;
@ -49,11 +51,32 @@ describe('GET /api/daemon/status', () => {
expect(typeof body.port).toBe('number');
expect(typeof body.pid).toBe('number');
expect(typeof body.installedPlugins).toBe('number');
expect(body.sandboxMode).toBe(false);
expect(body.sandbox).toEqual({ enabled: false });
expect(body.shuttingDown).toBe(false);
expect(body).not.toHaveProperty('namespace');
});
});
describe('GET /api/ready', () => {
it('returns a readiness snapshot for headless launchers', async () => {
const resp = await fetch(`${baseUrl}/api/ready`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
ok: boolean;
ready: boolean;
version: unknown;
};
expect(body.ok).toBe(true);
expect(body.ready).toBe(true);
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
expect(body).not.toHaveProperty('dataDir');
expect(body).not.toHaveProperty('sandboxMode');
expect(body).not.toHaveProperty('sandbox');
});
});
describe('POST /api/daemon/shutdown', () => {
it('only accepts requests from local-daemon-allowed origins', async () => {
// Without the local-daemon header, the route is rejected. The

View file

@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js';
import {
assertSandboxProjectRootAvailable,
detectEntryFile,
listFiles,
resolveProjectDir,
SandboxImportedProjectError,
} from '../src/projects.js';
function withSandboxMode<T>(run: () => T): T {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
describe('resolveProjectDir', () => {
const projectsRoot = '/var/od/projects';
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
}),
).not.toThrow();
});
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
withSandboxMode(() => {
const baseDir = '/Users/me/projects/site';
expect(
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() =>
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() => resolveProjectDir(projectsRoot, '../escape', {
kind: 'prototype',
baseDir,
})).toThrowError();
});
});
});
describe('detectEntryFile', () => {

View file

@ -1,6 +1,6 @@
import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -45,6 +45,17 @@ describe('POST /api/import/folder', () => {
});
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -62,6 +73,143 @@ describe('POST /api/import/folder', () => {
expect(body.entryFile).toBe('index.html');
});
it('rejects folder imports in sandbox mode', async () => {
await withSandboxMode(async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const resp = await importFolder({ baseDir: folder });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/OD_SANDBOX_MODE/i);
});
});
it('rejects sandbox runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const runResp = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(400);
const body = (await runResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const chatResp = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(chatResp.status).toBe(400);
const body = (await chatResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
expect(runsResp.status).toBe(200);
const runsBody = (await runsResp.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
});
});
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const binDir = makeFolder();
const cursorBin = path.join(
binDir,
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
);
await writeFile(
cursorBin,
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
);
await chmod(cursorBin, 0o755);
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const previousPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
try {
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editorId: 'cursor' }),
});
expect(resp.status).toBe(200);
const body = (await resp.json()) as { path?: string };
expect(body.path).toBe(await realpath(folder));
});
} finally {
if (previousPath == null) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
project?: { id?: string; metadata?: { baseDir?: string } };
};
expect(body.project?.id).toBe(project.id);
expect(body.project?.metadata?.baseDir).toBeTruthy();
});
});
it('rejects imported-folder project file listing in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('auto-detects the entry file when present', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '');

View file

@ -0,0 +1,194 @@
import type http from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
type StartedServer = {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
describe('POST /api/runs headless fallbacks', () => {
let started: StartedServer | null = null;
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
afterEach(async () => {
await Promise.resolve(started?.shutdown?.());
if (started?.server) {
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
}
started = null;
if (oldPath === undefined) delete process.env.PATH;
else process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
});
it('binds omitted conversationId to the seeded project conversation', async () => {
started = await startTestServer();
const { projectId, conversationId: seededConversationId } = await createProject(
started.url,
'Headless default conversation project',
);
await delay(5);
const newerConversationId = await createConversation(started.url, projectId, 'Newer user chat');
const conversationsResponse = await fetch(
`${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations`,
);
expect(conversationsResponse.status).toBe(200);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
expect(conversationsBody.conversations[0]?.id).toBe(newerConversationId);
const runResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: `missing-agent-${randomUUID()}`,
projectId,
message: 'Headless prompt',
}),
});
expect(runResponse.status).toBe(202);
const runBody = await runResponse.json() as { conversationId: string | null };
expect(runBody.conversationId).toBe(seededConversationId);
});
it('falls back past a stale saved agent to the first detected available runtime', async () => {
started = await startTestServer();
const binDir = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-bin-'));
const emptyAgentHome = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-home-'));
const priorConfig = await readAppConfigFromServer(started.url);
try {
const opencodeBin = await writeFakeOpencode(binDir);
process.env.PATH = '';
process.env.OD_AGENT_HOME = emptyAgentHome;
const configResponse = await fetch(`${started.url}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
agentCliEnv: {
claude: { CLAUDE_BIN: path.join(binDir, 'missing-claude') },
opencode: { OPENCODE_BIN: opencodeBin },
},
}),
});
expect(configResponse.status).toBe(200);
const { projectId } = await createProject(started.url, 'Headless stale agent project');
const runResponse = await fetch(`${started.url}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
message: 'Headless prompt',
}),
});
expect(runResponse.status).toBe(202);
const runBody = await runResponse.json() as { runId: string };
const statusResponse = await fetch(
`${started.url}/api/runs/${encodeURIComponent(runBody.runId)}`,
);
expect(statusResponse.status).toBe(200);
const statusBody = await statusResponse.json() as { agentId: string | null };
expect(statusBody.agentId).toBe('opencode');
} finally {
await restoreAppConfig(started.url, priorConfig);
await rm(binDir, { recursive: true, force: true });
await rm(emptyAgentHome, { recursive: true, force: true });
}
});
});
async function startTestServer(): Promise<StartedServer> {
return await startServer({ port: 0, returnServer: true }) as StartedServer;
}
async function createProject(url: string, name: string): Promise<{
projectId: string;
conversationId: string;
}> {
const projectId = `project_${randomUUID()}`;
const response = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name,
metadata: { kind: 'prototype' },
}),
});
expect(response.status).toBe(200);
const body = await response.json() as { conversationId: string };
return { projectId, conversationId: body.conversationId };
}
async function createConversation(
url: string,
projectId: string,
title: string,
): Promise<string> {
const response = await fetch(`${url}/api/projects/${encodeURIComponent(projectId)}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
expect(response.status).toBe(200);
const body = await response.json() as { conversation: { id: string } };
return body.conversation.id;
}
async function readAppConfigFromServer(url: string): Promise<Record<string, unknown>> {
const response = await fetch(`${url}/api/app-config`);
expect(response.status).toBe(200);
const body = await response.json() as { config?: Record<string, unknown> };
return body.config ?? {};
}
async function restoreAppConfig(url: string, config: Record<string, unknown>): Promise<void> {
await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: Object.hasOwn(config, 'agentId') ? config.agentId : null,
agentCliEnv: Object.hasOwn(config, 'agentCliEnv') ? config.agentCliEnv : null,
}),
});
}
async function writeFakeOpencode(dir: string): Promise<string> {
const bin = path.join(dir, 'opencode');
await writeFile(bin, `#!/usr/bin/env node
if (process.argv.includes('--version')) {
console.log('opencode 0.0.0');
process.exit(0);
}
if (process.argv[2] === 'models') {
console.log('test/model');
process.exit(0);
}
if (process.argv[2] === 'run') {
process.stdin.resume();
process.stdin.on('end', () => process.exit(0));
setTimeout(() => process.exit(0), 50);
} else {
process.exit(0);
}
`, 'utf8');
await chmod(bin, 0o755);
return bin;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -68,10 +68,14 @@ process.exit(0);
async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
const body = (await r.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
}).catch(() => {});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
async function createProject(): Promise<{ id: string; dir: string }> {
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
const id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
body: JSON.stringify({ id, name: id }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { conversationId: string };
projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by
// having the daemon return the upload root, then composing path manually.
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return { id, dir: join(projectsBase, id) };
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
}
async function importFolderProject(): Promise<{
id: string;
dir: string;
externalDir: string;
conversationId: string;
}> {
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
tempDirs.push(externalDir);
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
const r = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseDir: externalDir }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { project: { id: string }; conversationId: string };
projectsToClean.push(body.project.id);
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return {
id: body.project.id,
dir: join(projectsBase, body.project.id),
externalDir,
conversationId: body.conversationId,
};
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
});
}, 30_000);
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
await withFakeClaude(async () => {
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'sandbox-run',
transport: 'sse',
enabled: true,
url: 'https://mcp.example.test',
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir, externalDir, conversationId } = await importFolderProject();
await withSandboxMode(async () => {
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello sandbox mcp',
}),
});
expect(chatRes.status).toBe(400);
const body = (await chatRes.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const managedTarget = join(dir, '.mcp.json');
expect(existsSync(managedTarget)).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
});
}, 30_000);
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
const { id } = await importFolderProject();
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsBeforeRes.ok).toBe(true);
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
conversations: Array<{ id: string }>;
};
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
let routineId: string | null = null;
try {
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Sandbox imported folder routine',
prompt: 'try to run inside an imported folder',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: id },
agentId: 'claude',
enabled: false,
}),
});
expect(createRoutineRes.status).toBe(201);
const createRoutineBody = (await createRoutineRes.json()) as {
routine: { id: string };
};
routineId = createRoutineBody.routine.id;
await withSandboxMode(async () => {
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
method: 'POST',
});
expect(runRoutineRes.status).toBe(500);
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
expect(routineRunsRes.ok).toBe(true);
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
expect(routineRunsBody.runs).toHaveLength(0);
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
expect(runsRes.ok).toBe(true);
const runsBody = (await runsRes.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsAfterRes.ok).toBe(true);
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
conversations: Array<{ id: string }>;
};
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
conversationIdsBefore,
);
} finally {
if (routineId) {
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
}
}
}, 30_000);
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
await withFakeClaude(async () => {
const { id, dir } = await createProject();
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello run-scoped mcp',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
},
{
id: 'run-remote',
transport: 'http',
enabled: true,
authMode: 'none',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
},
],
},
}),
});
expect(chatRes.status).toBe(202);
const { runId } = (await chatRes.json()) as { runId: string };
const status = await waitForRunStatus(baseUrl, runId) as {
status: string;
toolBundle?: { mcpServers?: Array<{ id: string }> };
};
expect(status.status).toBe('succeeded');
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
'run-local',
'run-remote',
]);
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(true);
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
expect(written.mcpServers.run_local).toBeUndefined();
expect(written.mcpServers['run-local']).toMatchObject({
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
});
expect(written.mcpServers['run-remote']).toMatchObject({
type: 'http',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
});
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
expect(persistedRes.ok).toBe(true);
const persisted = (await persistedRes.json()) as { servers: unknown[] };
expect(persisted.servers).toEqual([]);
});
}, 30_000);
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
const { id, dir, externalDir, conversationId } = await importFolderProject();
const runsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported run-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(runsRes.status).toBe(400);
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
const chatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported chat-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local-chat',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(chatRes.status).toBe(400);
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
});
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
const { id } = await createProject();
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
},
}),
});
expect(invalidRunsRes.status).toBe(400);
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: 'bad',
}),
});
expect(invalidChatRes.status).toBe(400);
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle must be an object');
});
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
const { id, conversationId } = await createProject();
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'codex',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(unsupportedRuntimeRes.status).toBe(400);
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
error?: { message?: string };
};
expect(unsupportedRuntimeBody.error?.message).toContain(
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'hermes',
projectId: id,
message: 'bad remote tools',
toolBundle: {
mcpServers: [
{
id: 'run-remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
},
}),
});
expect(unsupportedTransportRes.status).toBe(400);
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
error?: { message?: string };
};
expect(unsupportedTransportBody.error?.message).toContain(
'Hermes (hermes) only supports stdio run-scoped MCP servers',
);
});
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path

View file

@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
);
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
}
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
delete process.env.OD_SANDBOX_MODE;
});
afterEach(async () => {
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
if (originalSandboxMode == null) {
delete process.env.OD_SANDBOX_MODE;
} else {
process.env.OD_SANDBOX_MODE = originalSandboxMode;
}
homedirSpy.mockRestore();
await rm(homeDir, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
@ -124,6 +131,30 @@ describe('media-config OpenAI auth-file fallback', () => {
});
});
it('does not read host OpenAI auth files in sandbox mode', async () => {
process.env.OD_SANDBOX_MODE = '1';
await writeHomeJson('.hermes/auth.json', {
providers: {
'openai-codex': {
tokens: { access_token: 'hermes-oauth-token' },
},
},
});
await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' },
OPENAI_API_KEY: 'host-codex-api-key',
});
const resolved = await resolveProviderConfig(projectRoot, 'openai');
const masked = await readMaskedConfig(projectRoot);
expect(resolved.apiKey).toBe('');
expect(openaiProvider(masked)).toMatchObject({
configured: false,
source: 'unset',
});
});
it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' },

View file

@ -1,11 +1,12 @@
import type http from 'node:http';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
type FakeMediaEndpoint = 'tool' | 'legacy';
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
let binDir: string;
let oldPath: string | undefined;
let oldCapture: string | undefined;
let oldMemoryConfigRaw: string | null = null;
let server: http.Server | null = null;
let shutdown: (() => Promise<void> | void) | undefined;
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
oldPath = process.env.PATH;
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`;
const memoryConfig = memoryConfigPath();
oldMemoryConfigRaw = await readFile(memoryConfig, 'utf8').catch(() => null);
await writeMemoryConfig(process.env.OD_DATA_DIR!, {
chatExtractionEnabled: false,
extraction: null,
});
});
afterEach(async () => {
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
else process.env.PATH = oldPath;
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture;
const memoryConfig = memoryConfigPath();
if (oldMemoryConfigRaw === null) {
await rm(memoryConfig, { force: true });
} else {
await mkdir(path.dirname(memoryConfig), { recursive: true });
await writeFile(memoryConfig, oldMemoryConfigRaw);
}
oldMemoryConfigRaw = null;
await rm(tempDir, { recursive: true, force: true });
await rm(binDir, { recursive: true, force: true });
});
@ -468,6 +484,10 @@ describe('run-scoped media policy routes', () => {
};
}
function memoryConfigPath(): string {
return path.join(memoryDir(process.env.OD_DATA_DIR!), '.config.json');
}
async function writeFakeAgent(
capturePath: string,
requestBody: unknown,

View file

@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
});
test('conversation summaries expose cumulative completed run duration', () => {
const db = createDb();
insertProject(db, {
id: 'project-duration',
name: 'project-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-duration-conversation',
projectId: 'project-duration',
title: 'Duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-first',
role: 'assistant',
content: 'first done',
runId: 'project-duration-first-run',
runStatus: 'succeeded',
startedAt: 10_000,
endedAt: 40_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-running',
role: 'assistant',
content: 'still running',
runId: 'project-duration-running-run',
runStatus: 'running',
startedAt: 45_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-second',
role: 'assistant',
content: 'second done',
runId: 'project-duration-second-run',
runStatus: 'failed',
startedAt: 50_000,
endedAt: 125_000,
});
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 105_000);
assert.equal(fetched?.totalDurationMs, 105_000);
});
test('conversation summaries include usage-only terminal run durations', () => {
const db = createDb();
insertProject(db, {
id: 'project-usage-duration',
name: 'project-usage-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-usage-duration-conversation',
projectId: 'project-usage-duration',
title: 'Usage duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-imported',
role: 'assistant',
content: 'imported done',
runId: 'project-usage-duration-imported-run',
runStatus: 'succeeded',
events: [{ kind: 'usage', durationMs: 22_000 }],
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-timestamped',
role: 'assistant',
content: 'timestamped done',
runId: 'project-usage-duration-timestamped-run',
runStatus: 'succeeded',
startedAt: 30_000,
endedAt: 60_000,
});
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 52_000);
assert.equal(fetched?.totalDurationMs, 52_000);
});
test('conversation listing batches latest run summaries for large projects', () => {
const db = createDb();
insertProject(db, {

View file

@ -13,7 +13,7 @@
*/
import type http from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { mkdir, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(detail.resolvedDir).toBe(baseDir);
});
it('keeps imported-folder resolvedDir stable in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDir: folder }),
});
expect(importResp.status).toBe(200);
const importBody = (await importResp.json()) as {
project: { id: string; metadata?: { baseDir?: string } };
};
const projectId = importBody.project.id;
const baseDir = importBody.project.metadata?.baseDir;
expect(baseDir).toBeTruthy();
await withSandboxMode(async () => {
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(200);
const detail = (await detailResp.json()) as {
project: { id: string };
resolvedDir: string;
};
expect(detail.project.id).toBe(projectId);
expect(detail.resolvedDir).toBe(baseDir);
});
});
it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
const projectId = `proj-routes-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
@ -269,3 +298,652 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
});
});
// ---------------------------------------------------------------------------
// Project locations routes: GET, PUT, scan, and project creation under an
// external project location.
// ---------------------------------------------------------------------------
describe('project locations routes', () => {
let server: http.Server;
let baseUrl: string;
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
afterAll(() => {
return new Promise<void>((resolve) => server.close(() => resolve()));
});
function makeTempDir(): string {
const d = mkdtempSync(path.join(tmpdir(), 'od-proj-loc-routes-'));
tempDirs.push(d);
return d;
}
async function putProjectLocations(
locations: Array<{ id?: string; name?: string; path: string }>,
): Promise<Response> {
return fetch(`${baseUrl}/api/project-locations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
});
}
async function putAppConfig(config: Record<string, unknown>): Promise<Response> {
return fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
}
it('GET /api/project-locations returns built-in default plus empty external', async () => {
const resp = await fetch(`${baseUrl}/api/project-locations`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as { locations: Array<{ id: string; name: string; builtIn?: boolean; path: string }> };
expect(body.locations).toHaveLength(1); // only default on fresh start
const loc0 = body.locations[0]!;
expect(loc0.id).toBe('default');
expect(loc0.builtIn).toBe(true);
expect(loc0.name).toBe('Open Design projects');
});
it('PUT /api/project-locations creates external roots and GET returns them alongside default', async () => {
const extDir = makeTempDir();
const resp = await putProjectLocations([
{ id: 'ext-root', name: 'External', path: extDir },
]);
expect(resp.status).toBe(200);
const putBody = (await resp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
expect(putBody.locations).toHaveLength(2);
const putLoc0 = putBody.locations[0]!;
const putLoc1 = putBody.locations[1]!;
expect(putLoc0.id).toBe('default');
expect(putLoc1.id).toBe('ext-root');
expect(putLoc1.path).toBe(await realpath(extDir));
// GET returns the same
const getResp = await fetch(`${baseUrl}/api/project-locations`);
expect(getResp.status).toBe(200);
const getBody = (await getResp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
expect(getBody.locations).toHaveLength(2);
const getLoc0 = getBody.locations[0]!;
const getLoc1 = getBody.locations[1]!;
expect(getLoc0.id).toBe('default');
expect(getLoc1.id).toBe('ext-root');
});
it('POST /api/project-locations/scan returns empty result when no manifests found', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'empty-ext', name: 'Empty', path: extDir }]);
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scanResp.status).toBe(200);
const body = (await scanResp.json()) as {
scanned: number;
imported: unknown[];
existing: string[];
skipped: unknown[];
};
expect(body.scanned).toBe(0);
expect(body.imported).toEqual([]);
});
it('POST /api/project-locations/scan imports manifest-backed project and skips on re-scan', async () => {
const extDir = makeTempDir();
// Create a project directory with a valid manifest
const projectDir = path.join(extDir, 'scan-test-proj');
const odDir = path.join(projectDir, '.open-design');
await mkdir(odDir, { recursive: true });
const manifest = {
schemaVersion: 1 as const,
id: 'scan-test-proj',
name: 'Scanned Project',
createdAt: Date.now(),
updatedAt: Date.now(),
skillId: null,
designSystemId: null,
};
await writeFile(
path.join(projectDir, '.open-design', 'project.json'),
JSON.stringify(manifest, null, 2),
'utf8',
);
// Register the location
await putProjectLocations([{ id: 'scan-ext', name: 'Scan External', path: extDir }]);
// First scan: should import
const scan1 = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scan1.status).toBe(200);
const body1 = (await scan1.json()) as {
scanned: number;
imported: Array<{ id: string; name: string; metadata?: { baseDir?: string; importedFrom?: string } }>;
existing: string[];
skipped: unknown[];
};
expect(body1.scanned).toBeGreaterThanOrEqual(1);
expect(body1.imported).toHaveLength(1);
const imported0 = body1.imported[0]!;
expect(imported0.id).toBe('scan-test-proj');
expect(imported0.name).toBe('Scanned Project');
// The imported project should have metadata pointing at the external dir
// (ensureProjectLocation calls realpath which resolves /var -> /private/var on macOS)
expect(imported0.metadata?.baseDir).toBe(await realpath(projectDir));
expect(imported0.metadata?.importedFrom).toBe('project-location');
expect(body1.existing).toEqual([]);
// Second scan: project already exists, should be in "existing"
const scan2 = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scan2.status).toBe(200);
const body2 = (await scan2.json()) as {
scanned: number;
imported: unknown[];
existing: string[];
};
expect(body2.imported).toEqual([]);
expect(body2.existing).toEqual(['scan-test-proj']);
});
it('POST /api/projects with projectLocationId creates project under external root and writes .open-design/project.json', async () => {
const extDir = makeTempDir();
// Register an external location
await putProjectLocations([{ id: 'create-ext', name: 'Create External', path: extDir }]);
const projectId = `ext-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'External Project',
skillId: null,
designSystemId: null,
projectLocationId: 'create-ext',
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
};
expect(createBody.project.id).toBe(projectId);
expect(createBody.project.metadata?.importedFrom).toBe('project-location');
expect(createBody.project.metadata?.projectLocationId).toBe('create-ext');
// The project should be under <extDir>/<projectId> (ensureProjectLocation realpaths)
const expectedProjectDir = await realpath(path.join(extDir, projectId));
expect(createBody.project.metadata?.baseDir).toBe(expectedProjectDir);
// Verify .open-design/project.json was written
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
const manifestRaw = await import('node:fs/promises').then((m) => m.readFile(manifestPath, 'utf8'));
const manifest = JSON.parse(manifestRaw);
expect(manifest.schemaVersion).toBe(1);
expect(manifest.id).toBe(projectId);
expect(manifest.name).toBe('External Project');
// GET /api/projects/:id resolvedDir equals the external project dir
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(200);
const detail = (await detailResp.json()) as { resolvedDir: string };
expect(detail.resolvedDir).toBe(expectedProjectDir);
});
it('POST /api/projects uses the configured default project location when no location is supplied', async () => {
const extDir = makeTempDir();
const locationId = 'default-create-location';
await putProjectLocations([{ id: locationId, name: 'Default External', path: extDir }]);
const cfgResp = await putAppConfig({ defaultProjectLocationId: locationId });
expect(cfgResp.status).toBe(200);
const projectId = `default-location-project-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Default location project',
skillId: null,
designSystemId: null,
}),
});
expect(createResp.status).toBe(200);
const body = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; projectLocationId?: string; importedFrom?: string } };
};
expect(body.project.metadata?.projectLocationId).toBe(locationId);
expect(body.project.metadata?.importedFrom).toBe('project-location');
expect(body.project.metadata?.baseDir).toBe(await realpath(path.join(extDir, projectId)));
await putAppConfig({ defaultProjectLocationId: null });
await putProjectLocations([]);
});
it('POST /api/projects falls back to built-in storage when configured default location is unavailable', async () => {
await putProjectLocations([]);
const cfgResp = await putAppConfig({ defaultProjectLocationId: 'missing-location' });
expect(cfgResp.status).toBe(200);
const projectId = `missing-default-project-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Missing default project',
skillId: null,
designSystemId: null,
}),
});
expect(createResp.status).toBe(200);
const body = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; projectLocationId?: string } };
};
expect(body.project.metadata?.baseDir).toBeUndefined();
expect(body.project.metadata?.projectLocationId).toBeUndefined();
await putAppConfig({ defaultProjectLocationId: null });
});
it('PATCH /api/projects/:id preserves project-location provenance with baseDir', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'patch-ext', name: 'Patch External', path: extDir }]);
const projectId = `ext-patch-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Patch External Project',
projectLocationId: 'patch-ext',
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
};
const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { kind: 'prototype', skipDiscoveryBrief: true } }),
});
expect(patchResp.status).toBe(200);
const patchBody = (await patchResp.json()) as {
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string; skipDiscoveryBrief?: boolean } };
};
expect(patchBody.project.metadata?.baseDir).toBe(createBody.project.metadata?.baseDir);
expect(patchBody.project.metadata?.importedFrom).toBe('project-location');
expect(patchBody.project.metadata?.projectLocationId).toBe('patch-ext');
expect(patchBody.project.metadata?.skipDiscoveryBrief).toBe(true);
});
it('POST /api/projects with unknown projectLocationId returns 400', async () => {
const projectId = `bad-loc-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Bad Location Project',
projectLocationId: 'nonexistent-location-id',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/project location/i);
});
it('POST /api/projects with invalid designSystemId does not create external project directory', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'invalid-ds-ext', name: 'Invalid DS External', path: extDir }]);
const projectId = `invalid-ds-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Invalid design system project',
designSystemId: `missing-design-system-${Date.now()}`,
projectLocationId: 'invalid-ds-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe('DESIGN_SYSTEM_NOT_FOUND');
await expect(readdir(extDir)).resolves.toEqual([]);
});
it('PUT /api/project-locations rejects non-array locations body', async () => {
const resp = await fetch(`${baseUrl}/api/project-locations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: 'not-an-array' }),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
// -----------------------------------------------------------------------
// Security boundaries — see #451 (project-locations) for context.
// -----------------------------------------------------------------------
it('POST /api/projects with projectLocationId rejects unsafe id "."', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sec-ext', name: 'Security External', path: extDir }]);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: '.',
name: 'Dot Project',
projectLocationId: 'sec-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/invalid project id/i);
});
it('POST /api/projects with projectLocationId rejects unsafe id ".."', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sec-ext2', name: 'Security External 2', path: extDir }]);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: '..',
name: 'DotDot Project',
projectLocationId: 'sec-ext2',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/invalid project id/i);
});
it('POST /api/projects with projectLocationId rejects when target path already exists as a symlink', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sym-ext', name: 'Symlink External', path: extDir }]);
const projectId = `symlink-proj-${Date.now()}`;
const realTargetDir = path.join(extDir, 'real-target');
await mkdir(realTargetDir, { recursive: true });
// Pre-create a symlink at <extDir>/<projectId> pointing to another directory
const symlinkPath = path.join(extDir, projectId);
await symlink(realTargetDir, symlinkPath);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Symlink Project',
projectLocationId: 'sym-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
it('PUT /api/project-locations rejects a root overlapping the daemon projects dir', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR required for daemon route tests');
const projectsDir = path.join(dataDir, 'projects');
const canonicalProjectsDir = await realpath(projectsDir);
const resp = await putProjectLocations([
{ id: 'overlap-projects', name: 'Overlap Projects', path: canonicalProjectsDir },
]);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/cannot overlap|daemon data/i);
});
it('PUT /api/project-locations rejects filesystem root "/" via isBlocked check', async () => {
// isBlocked in linked-dirs.ts rejects the filesystem root.
const resp = await putProjectLocations([
{ id: 'root-loc', name: 'Root', path: '/' },
]);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
it('app-config bypass: PUT /api/app-config persists invalid path but GET /api/project-locations does not expose it', async () => {
// Persist a projectLocations entry with a system-protected path ('/') via
// the generic PUT /api/app-config route, which only validates format, not
// safety. The GET /api/project-locations route must filter it out because
// configuredProjectLocations() runs validateLinkedDirs + locationOverlapsDaemonData.
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectLocations: [
{ id: 'bad-root', name: 'Bad Root', path: '/' },
],
}),
});
expect(appCfgResp.status).toBe(200);
// Verify the persisted config (read back) contains the entry (format validation passed)
const readCfgResp = await fetch(`${baseUrl}/api/app-config`);
expect(readCfgResp.status).toBe(200);
const cfgBody = (await readCfgResp.json()) as {
config: { projectLocations?: Array<{ id: string; path: string }> };
};
// The entry was normalized and persisted
const locs = cfgBody.config.projectLocations;
expect(locs).toBeDefined();
expect(locs!.length).toBeGreaterThanOrEqual(1);
// But GET /api/project-locations must NOT expose it
const locResp = await fetch(`${baseUrl}/api/project-locations`);
expect(locResp.status).toBe(200);
const locBody = (await locResp.json()) as {
locations: Array<{ id: string }>;
};
const ids = locBody.locations.map((l) => l.id);
expect(ids).toContain('default'); // built-in always present
// The invalid location must not appear
expect(ids).not.toContain('bad-root');
// Clean up: remove the invalid projectLocations
await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectLocations: [] }),
});
});
it('app-config bypass: POST /api/projects with invalid persisted root id returns 400 unknown project location', async () => {
// Persist a projectLocations entry with '/' via app-config.
// The auto-generated id follows the loc_<base64url> pattern.
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectLocations: [
{ id: 'evil-root', name: 'Evil Root', path: '/' },
],
}),
});
expect(appCfgResp.status).toBe(200);
// Try to create a project under this location id. Since configuredProjectLocations
// filters it, the lookup returns nothing → 400 "unknown project location".
const projectId = `evil-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Evil Project',
projectLocationId: 'evil-root',
}),
});
expect(createResp.status).toBe(400);
const body = (await createResp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/unknown project location/i);
// Clean up
await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectLocations: [] }),
});
});
it('removing an external location hides its projects but preserves DB history and disk files for re-scan', async () => {
const extDir = makeTempDir();
const locationId = 'unreg-loc';
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
// Create a project under this external location
const projectId = `unreg-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Project To Unregister',
skillId: null,
designSystemId: null,
projectLocationId: locationId,
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { id: string };
conversationId: string;
};
expect(createBody.project.id).toBe(projectId);
const messageId = `msg-${Date.now()}`;
const messageResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages/${messageId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'user',
content: 'restore this conversation after location re-add',
}),
});
expect(messageResp.status).toBe(200);
// Confirm the project is listed
const listBefore = await fetch(`${baseUrl}/api/projects`);
expect(listBefore.status).toBe(200);
const beforeBody = (await listBefore.json()) as { projects: Array<{ id: string }> };
expect(beforeBody.projects.some((p) => p.id === projectId)).toBe(true);
// The project directory and manifest should exist on disk
const expectedProjectDir = await realpath(path.join(extDir, projectId));
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
const manifestBefore = await readFile(manifestPath, 'utf8');
expect(JSON.parse(manifestBefore).id).toBe(projectId);
// Remove the external location: PUT empty locations so the location is dropped.
// This is an unmount/hide operation, not a destructive project delete.
const removeResp = await putProjectLocations([]);
expect(removeResp.status).toBe(200);
const removeBody = (await removeResp.json()) as {
locations: Array<{ id: string }>;
removedProjectIds?: string[];
};
// The response must include removedProjectIds with our project
expect(removeBody.removedProjectIds).toBeDefined();
expect(removeBody.removedProjectIds).toContain(projectId);
// Only the built-in default location should remain
expect(removeBody.locations).toHaveLength(1);
expect(removeBody.locations[0]!.id).toBe('default');
// The project should no longer appear in GET /api/projects
const listAfter = await fetch(`${baseUrl}/api/projects`);
expect(listAfter.status).toBe(200);
const afterBody = (await listAfter.json()) as { projects: Array<{ id: string }> };
expect(afterBody.projects.some((p) => p.id === projectId)).toBe(false);
// GET /api/projects/:id should return 404 while the location is unmounted.
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(404);
// The on-disk project directory and manifest must still be intact
const manifestAfter = await readFile(manifestPath, 'utf8');
expect(JSON.parse(manifestAfter).id).toBe(projectId);
// Re-add the same base and scan: the existing DB row should be revealed,
// not recreated from only the manifest, so conversation history survives.
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { method: 'POST' });
expect(scanResp.status).toBe(200);
const scanBody = (await scanResp.json()) as { imported: Array<{ id: string }>; existing: string[] };
expect(scanBody.imported.some((p) => p.id === projectId)).toBe(false);
expect(scanBody.existing).toContain(projectId);
const listReadded = await fetch(`${baseUrl}/api/projects`);
expect(listReadded.status).toBe(200);
const readdedBody = (await listReadded.json()) as { projects: Array<{ id: string }> };
expect(readdedBody.projects.some((p) => p.id === projectId)).toBe(true);
const messagesResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages`);
expect(messagesResp.status).toBe(200);
const messagesBody = (await messagesResp.json()) as { messages: Array<{ id: string; content: string }> };
expect(messagesBody.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: messageId,
content: 'restore this conversation after location re-add',
}),
]),
);
});
});
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}

View file

@ -37,6 +37,23 @@ describe('resolveDataDir', () => {
expect(resolveDataDir('', projectRoot)).toBe(path.join(projectRoot, '.od'));
});
it('requires an explicit OD_DATA_DIR when sandbox mode requires one', () => {
expect(() =>
resolveDataDir(undefined, projectRoot, { requireExplicit: true }),
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
expect(() => resolveDataDir('', projectRoot, { requireExplicit: true })).toThrow(
'OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled',
);
expect(() =>
resolveDataDir(' ', projectRoot, { requireExplicit: true }),
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
});
it('trims OD_DATA_DIR before resolving the storage root', () => {
const out = resolveDataDir(' rel-od ', projectRoot, { requireExplicit: true });
expect(out).toBe(path.join(projectRoot, 'rel-od'));
});
it('expands a leading ~/ against the user home directory', () => {
const out = resolveDataDir('~/od-test', projectRoot);
expect(out).toBe(path.join(fakeHome, 'od-test'));

View file

@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
normalizeRunToolBundleForRun,
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
summarizeRunToolBundle,
validateRunToolBundleForAgent,
} from '../src/run-tool-bundle.js';
describe('run-scoped tool bundles', () => {
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
const bundle = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
{
id: 'remote-tools',
transport: 'http',
url: 'https://example.test/mcp',
headers: { Authorization: 'Bearer secret' },
},
{
id: '../bad',
transport: 'stdio',
command: 'node',
},
],
});
expect(bundle.mcpServers).toHaveLength(2);
expect(bundle.mcpServers[0]).toMatchObject({
id: 'local-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const summary = summarizeRunToolBundle(bundle);
expect(summary).toEqual({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
enabled: true,
},
{
id: 'remote-tools',
transport: 'http',
enabled: true,
authMode: 'oauth',
},
],
});
expect(JSON.stringify(summary)).not.toContain('secret');
expect(JSON.stringify(summary)).not.toContain('server.js');
});
it('uses only run-scoped MCP servers in sandbox mode', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'persisted',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'run-only',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: true,
});
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
expect([...selection.persistedTokenServerIds]).toEqual([]);
});
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
expect(parseRunToolBundleForRequest('bad')).toEqual({
ok: false,
message: 'toolBundle must be an object',
});
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
ok: false,
message: 'toolBundle.mcpServers must be an array',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[0] is invalid',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'dup',
transport: 'stdio',
command: 'node',
},
{
id: 'dup',
transport: 'http',
url: 'https://example.test/mcp',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
});
});
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
{
id: 'persisted-only',
transport: 'http',
url: 'https://persisted-only.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://run.example.test/mcp',
headers: { Authorization: 'Bearer run-token' },
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: false,
});
expect(selection.enabledServers).toHaveLength(2);
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
url: 'https://run.example.test/mcp',
});
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
});
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
const stdioOnly = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local',
transport: 'stdio',
command: 'node',
},
],
});
const remote = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
});
expect(validateRunToolBundleForAgent(stdioOnly, {
id: 'codex',
name: 'Codex CLI',
})).toEqual({
ok: false,
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'hermes',
name: 'Hermes',
externalMcpInjection: 'acp-merge',
})).toEqual({
ok: false,
message:
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
})).toEqual({ ok: true });
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
}, {
deliveryTarget: 'external-project',
})).toEqual({
ok: false,
message:
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
'so toolBundle requires a daemon-managed project',
});
});
});

View file

@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
});
});
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
const runs = createRuns();
const run = runs.create({
projectId: 'project-1',
conversationId: 'conv-a',
toolBundle: {
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
],
},
}) as any;
expect(run.toolBundle.mcpServers).toHaveLength(1);
expect(run.toolBundle.mcpServers[0]).toMatchObject({
id: 'run-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const status = runs.statusBody(run);
expect(status.toolBundle).toEqual({
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
enabled: true,
},
],
});
expect(JSON.stringify(status)).not.toContain('secret');
expect(JSON.stringify(status)).not.toContain('server.js');
});
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });

View file

@ -1,6 +1,8 @@
import { symlinkSync } from 'node:fs';
import { test, vi } from 'vitest';
import { homedir } from 'node:os';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as platform from '@open-design/platform';
import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
@ -8,6 +10,7 @@ import {
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
@ -55,6 +58,113 @@ test('spawnEnvForAgent applies configured Codex env without mutating the base en
assert.equal('CODEX_BIN' in base, false);
});
test('spawnEnvForAgent reapplies sandbox state roots after configured env overrides', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-'));
try {
const codexEnv = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
},
);
assert.equal(
codexEnv.CODEX_HOME,
join(dataDir, 'sandbox', 'agent-home', '.codex'),
);
assert.equal(codexEnv.HOME, join(dataDir, 'sandbox', 'agent-home'));
const claudeEnv = spawnEnvForAgent(
'claude',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CLAUDE_CONFIG_DIR: '/Users/test/.claude-host',
},
);
assert.equal(
claudeEnv.CLAUDE_CONFIG_DIR,
join(dataDir, 'sandbox', 'config', 'claude'),
);
const amrEnv = spawnEnvForAgent(
'amr',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
OPENCODE_TEST_HOME: '/Users/test/.opencode-host',
},
);
assert.equal(
amrEnv.OPENCODE_TEST_HOME,
join(dataDir, 'sandbox', 'agent-home', '.opencode'),
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent keeps sandbox roots pinned to the base OD_DATA_DIR', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-base-'));
try {
const env = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: dataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
OD_DATA_DIR: '/host/path/.od',
},
);
assert.equal(env.OD_DATA_DIR, dataDir);
assert.equal(env.CODEX_HOME, join(dataDir, 'sandbox', 'agent-home', '.codex'));
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent resolves relative OD_DATA_DIR before applying sandbox roots', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-relative-'));
try {
const relativeDataDir = relative(repoRoot, dataDir);
const env = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: relativeDataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
},
);
assert.equal(
env.CODEX_HOME,
join(dataDir, 'sandbox', 'agent-home', '.codex'),
);
assert.equal(env.CLAUDE_CONFIG_DIR, join(dataDir, 'sandbox', 'config', 'claude'));
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
const env = spawnEnvForAgent(
'gemini',
@ -847,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
const env = spawnEnvForAgent(
'claude',
{
ANTHROPIC_API_KEY: 'sk-openclaude',
PATH: '/usr/bin',
},
{},
{},
{ resolvedBin: '/tools/openclaude' },
);
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {

View file

@ -1,4 +1,5 @@
import { test } from 'vitest';
import { relative, resolve } from 'node:path';
import {
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
@ -407,6 +408,77 @@ fsTest(
},
);
fsTest(
'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
() => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
const realPrefixBin = join(realPrefix, 'bin');
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(realPrefixBin, { recursive: true });
writeFileSync(join(realPrefixBin, 'gemini'), '');
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
delete process.env.OD_AGENT_HOME;
process.env.OD_DATA_DIR = dataDir;
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(
resolved,
null,
`sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
);
},
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
rmSync(realPrefix, { recursive: true, force: true });
}
},
);
fsTest(
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
() => {
const projectRoot = resolve(process.cwd(), '../..');
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
const dataDir = join(parent, 'data');
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(sandboxBin, { recursive: true });
const geminiPath = join(sandboxBin, 'gemini');
writeFileSync(geminiPath, '');
chmodSync(geminiPath, 0o755);
delete process.env.OD_AGENT_HOME;
delete process.env.NPM_CONFIG_PREFIX;
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, geminiPath);
},
);
} finally {
rmSync(parent, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
}
},
);
fsTest(
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
() => {

View file

@ -102,6 +102,31 @@ test('local agent profiles skip explicit unknown baseAgent without falling back'
}
});
test('sandbox mode ignores implicit and host explicit local agent profiles', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-sandbox-'));
try {
await withEnvSnapshot(['OD_AGENT_PROFILES_CONFIG', 'OD_SANDBOX_MODE', 'OD_DATA_DIR'], async () => {
const config = join(dir, 'agents.local.json');
writeFileSync(
config,
JSON.stringify({
agents: [{ id: 'explicit-wrapper', bin: 'explicit-wrapper' }],
}),
);
process.env.OD_SANDBOX_MODE = '1';
delete process.env.OD_DATA_DIR;
delete process.env.OD_AGENT_PROFILES_CONFIG;
assert.deepEqual(readLocalAgentProfileDefs(), []);
process.env.OD_AGENT_PROFILES_CONFIG = config;
assert.deepEqual(readLocalAgentProfileDefs(), []);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
process.env.OD_CODEX_DISABLE_PLUGINS = '1';

View file

@ -0,0 +1,98 @@
import os from 'node:os';
import path from 'node:path';
import { existsSync, mkdtempSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { afterEach, describe, expect, it } from 'vitest';
import {
applySandboxRuntimeEnv,
ensureSandboxRuntimeDirs,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
} from '../src/sandbox-mode.js';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
function tempDataDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), 'od-sandbox-mode-'));
tempDirs.push(dir);
return dir;
}
describe('sandbox mode env parsing', () => {
it('is disabled when OD_SANDBOX_MODE is unset or false-like', () => {
expect(isSandboxModeEnabled({})).toBe(false);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '0' })).toBe(false);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'false' })).toBe(false);
});
it('is enabled for explicit true-like values', () => {
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '1' })).toBe(true);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'true' })).toBe(true);
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'YES' })).toBe(true);
});
it('rejects ambiguous non-empty values', () => {
expect(() => isSandboxModeEnabled({ OD_SANDBOX_MODE: 'sandbox' })).toThrow(
'OD_SANDBOX_MODE must be one of',
);
});
});
describe('sandbox runtime roots', () => {
it('keeps all run-scoped roots under OD_DATA_DIR', () => {
const dataDir = tempDataDir();
const config = resolveSandboxRuntimeConfig(true, dataDir);
expect(config.enabled).toBe(true);
for (const dir of Object.values(config.roots)) {
expect(dir === dataDir || dir.startsWith(dataDir + path.sep)).toBe(true);
}
});
it('creates scoped runtime directories only when enabled', () => {
const dataDir = tempDataDir();
const enabled = resolveSandboxRuntimeConfig(true, dataDir);
const disabled = resolveSandboxRuntimeConfig(false, dataDir);
ensureSandboxRuntimeDirs(disabled);
expect(existsSync(enabled.roots.agentHomeDir)).toBe(false);
ensureSandboxRuntimeDirs(enabled);
expect(existsSync(enabled.roots.agentHomeDir)).toBe(true);
expect(existsSync(enabled.roots.previewStateDir)).toBe(true);
expect(existsSync(enabled.roots.toolConfigDir)).toBe(true);
});
it('pins agent home and tool config env to sandbox roots', () => {
const dataDir = tempDataDir();
const config = resolveSandboxRuntimeConfig(true, dataDir);
const env = applySandboxRuntimeEnv(
{
HOME: '/real/home',
CODEX_HOME: '/real/home/.codex',
CLAUDE_CONFIG_DIR: '/real/home/.claude',
OPENCODE_TEST_HOME: '/real/home/.opencode',
NPM_CONFIG_USERCONFIG: '/real/home/.npmrc',
OD_DATA_DIR: dataDir,
PATH: '/bin',
},
config,
);
expect(env.HOME).toBe(config.roots.agentHomeDir);
expect(env.USERPROFILE).toBe(config.roots.agentHomeDir);
expect(env.OD_AGENT_HOME).toBe(config.roots.agentHomeDir);
expect(env.CODEX_HOME).toBe(path.join(config.roots.agentHomeDir, '.codex'));
expect(env.CLAUDE_CONFIG_DIR).toBe(path.join(config.roots.configDir, 'claude'));
expect(env.OPENCODE_TEST_HOME).toBe(path.join(config.roots.agentHomeDir, '.opencode'));
expect(env.NPM_CONFIG_USERCONFIG).toBe(path.join(config.roots.toolConfigDir, 'npmrc'));
expect(env.PATH).toBe('/bin');
});
});

View file

@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test, vi } from 'vitest';
function withEnvSnapshot<T>(
keys: readonly string[],
run: () => T | Promise<T>,
): T | Promise<T> {
const snapshot = new Map(keys.map((key) => [key, process.env[key]]));
const restore = () => {
for (const key of keys) {
const value = snapshot.get(key);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
let result: T | Promise<T>;
try {
result = run();
} catch (error) {
restore();
throw error;
}
if (result instanceof Promise) {
return result.finally(restore);
}
restore();
return result;
}
test('sandbox runtime registry ignores host-local agent profiles at module load', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-'));
const dataDir = path.join(root, 'data');
const hostHome = path.join(root, 'host-home');
const hostConfigDir = path.join(hostHome, '.open-design');
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
const sandboxConfigDir = path.join(
dataDir,
'sandbox',
'agent-home',
'.open-design',
);
const sandboxConfig = path.join(sandboxConfigDir, 'agents.local.json');
try {
mkdirSync(hostConfigDir, { recursive: true });
mkdirSync(sandboxConfigDir, { recursive: true });
writeFileSync(
hostConfig,
JSON.stringify({
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
}),
);
writeFileSync(
sandboxConfig,
JSON.stringify({
agents: [
{
id: 'sandbox-wrapper',
baseAgent: 'claude',
bin: 'sandbox-wrapper',
},
],
}),
);
await withEnvSnapshot(
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
async () => {
process.env.OD_SANDBOX_MODE = '1';
process.env.OD_DATA_DIR = dataDir;
process.env.OD_AGENT_PROFILES_CONFIG = hostConfig;
vi.resetModules();
vi.doMock('node:os', async () => ({
...(await vi.importActual<typeof import('node:os')>('node:os')),
homedir: () => hostHome,
}));
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
const ids = AGENT_DEFS.map((def) => def.id);
assert.equal(ids.includes('host-wrapper'), false);
assert.equal(ids.includes('sandbox-wrapper'), true);
},
);
} finally {
vi.doUnmock('node:os');
vi.resetModules();
rmSync(root, { recursive: true, force: true });
}
});
test('sandbox runtime registry ignores implicit profiles without OD_DATA_DIR', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-missing-data-'));
const hostHome = path.join(root, 'host-home');
const hostConfigDir = path.join(hostHome, '.open-design');
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
try {
mkdirSync(hostConfigDir, { recursive: true });
writeFileSync(
hostConfig,
JSON.stringify({
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
}),
);
await withEnvSnapshot(
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
async () => {
process.env.OD_SANDBOX_MODE = '1';
delete process.env.OD_DATA_DIR;
delete process.env.OD_AGENT_PROFILES_CONFIG;
vi.resetModules();
vi.doMock('node:os', async () => ({
...(await vi.importActual<typeof import('node:os')>('node:os')),
homedir: () => hostHome,
}));
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
const ids = AGENT_DEFS.map((def) => def.id);
assert.equal(ids.includes('host-wrapper'), false);
},
);
} finally {
vi.doUnmock('node:os');
vi.resetModules();
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -200,6 +200,16 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
expect(out).not.toContain('Reference prompt template');
});
it('non-media dispatch hint includes fal-ai/* passthrough instruction', () => {
const out = composeSystemPrompt({
metadata: { kind: 'prototype' },
});
expect(out).toContain('## Media generation (if asked)');
expect(out).toContain('fal-ai/*');
expect(out).toContain('pass it through as-is without substitution');
});
it('renders without source attribution when the source field is missing', () => {
const { source: _omit, ...withoutSource } = baseSummary;
const out = composeSystemPrompt({
@ -420,8 +430,8 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
},
});
expect(out).toContain('`media generate` treats the handoff as');
expect(out).toContain('exit `0` so the first dispatch does not look like a failed shell call');
expect(out).toContain('always exits 0');
expect(out).toContain('as a handoff signal');
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
expect(out).toContain('either `file` or `taskId`');
expect(out).toContain('`2` from `media wait` is not a failure');

View file

@ -1,62 +0,0 @@
---
/*
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
* `/skills/scenario/<slug>/`, and any future faceted view.
*
* Renders a `<li class="catalog-row catalog-row-skill">` with the
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
* the markup so all faceted views stay visually identical to the
* unfiltered index.
*/
import type { SkillRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';
export interface Props {
skill: SkillRecord;
index: number;
}
const { skill, index } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
// so we deliberately bypass the precise IntersectionObserver pipeline.
// On long lists like /skills/instructions/ (96 rows) the observer's
// swap latency stranded mid-page rows on the SVG placeholder during
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
// lookahead) keeps the upcoming rows pre-fetched without the
// observer round-trip; only the first three rows go eager so they
// paint immediately on first paint instead of waiting for the
// browser's lazy queue.
const eager = index < 3;
---
<li class="catalog-row catalog-row-skill">
<a href={href(`/skills/${skill.slug}/`)}>
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
<span class="row-thumb">
{skill.previewUrl ? (
<img
src={skill.previewUrl}
alt=""
loading={eager ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={eager ? 'high' : 'auto'}
/>
) : (
<span class="row-thumb-empty" aria-hidden="true" />
)}
</span>
<span class="row-body">
<span class="row-name">{skill.name}</span>
<span class="row-desc">{skill.description}</span>
</span>
<span class="row-meta">
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>
</li>

View file

@ -1,8 +1,14 @@
---
/*
* Shared system card used on `/systems/` and
* `/systems/category/<slug>/`. Displays palette swatches, name,
* category, and tagline as a clickable card.
* Shared system card used on `/plugins/systems/`. Displays palette
* swatches, name, category, and tagline as a clickable card.
*
* The card links to `/systems/<slug>/`, which `public/_redirects`
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`)
* for the 142 systems that have one, and degrades the 8 without a
* detail page to `/plugins/systems/`. Linking through the redirect
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from
* pointing at a non-existent detail page.
*/
import type { SystemRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';

View file

@ -222,9 +222,9 @@ const INFO_PAGE_COPY: Partial<Record<LandingLocaleCode, InfoPageCopy>> = {
{ label: 'Community', name: 'Discord' },
{ label: 'Documentation', name: 'GitHub README' },
{ label: 'License', name: 'Apache-2.0' },
{ label: 'Skills catalog', name: '/skills/' },
{ label: 'Systems catalog', name: '/systems/' },
{ label: 'Templates catalog', name: '/templates/' },
{ label: 'Skills catalog', name: '/plugins/skills/' },
{ label: 'Systems catalog', name: '/plugins/systems/' },
{ label: 'Templates catalog', name: '/plugins/templates/' },
],
aliasesTitle: 'Naming & aliases',
aliasesLead:
@ -538,9 +538,9 @@ INFO_PAGE_COPY.zh = {
{ label: '社区', name: 'Discord' },
{ label: '文档', name: 'GitHub README' },
{ label: '许可证', name: 'Apache-2.0' },
{ label: 'Skill 目录', name: '/skills/' },
{ label: '系统目录', name: '/systems/' },
{ label: '模板目录', name: '/templates/' },
{ label: 'Skill 目录', name: '/plugins/skills/' },
{ label: '系统目录', name: '/plugins/systems/' },
{ label: '模板目录', name: '/plugins/templates/' },
],
aliasesTitle: '命名与别名',
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
@ -1027,9 +1027,9 @@ const sourceNames = [
'Discord',
'GitHub README',
'Apache-2.0',
'/skills/',
'/systems/',
'/templates/',
'/plugins/skills/',
'/plugins/systems/',
'/plugins/templates/',
] as const;
const aliasLabels = [

View file

@ -730,23 +730,23 @@ export default function Page({
</h2>
</div>
<div className='pills' data-reveal='right'>
<a className='pill active' href={href('/skills/')}>
<a className='pill active' href={href('/plugins/skills/')}>
{home.labs.pills.all}
<span className='count'>{skills}</span>
</a>
<a className='pill' href={href('/skills/mode/prototype/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.prototype}
<span className='count'>{prototypeCount}</span>
</a>
<a className='pill' href={href('/skills/mode/deck/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.deck}
<span className='count'>{deckCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.mobile}
<span className='count'>{mobileCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.office}
<span className='count'></span>
</a>
@ -839,7 +839,7 @@ export default function Page({
{home.labs.foot(skills)}
{NBSP}·{NBSP}
<a
href={href('/skills/')}
href={href('/plugins/skills/')}
className='library-link'
style={{ color: 'var(--coral)' }}
>
@ -953,7 +953,7 @@ export default function Page({
{home.work.titleSuffix}
<span className='dot'>.</span>
</h2>
<a className='work-link' href={href('/skills/')}>
<a className='work-link' href={href('/plugins/skills/')}>
{home.work.viewAll(skills)}
</a>
</div>
@ -1325,17 +1325,17 @@ export default function Page({
<h5>{home.footer.columns.library}</h5>
<ul>
<li>
<a href={href('/skills/')}>
<a href={href('/plugins/skills/')}>
{home.footer.libraryLinks.skills(skills)}
</a>
</li>
<li>
<a href={href('/systems/')}>
<a href={href('/plugins/systems/')}>
{home.footer.libraryLinks.systems(systems)}
</a>
</li>
<li>
<a href={href('/templates/')}>
<a href={href('/plugins/templates/')}>
{home.footer.libraryLinks.templates}
</a>
</li>

View file

@ -2,17 +2,7 @@
import { getCollection } from 'astro:content';
import Layout from '../../_components/sub-page-layout.astro';
import type { HeaderProps } from '../../_components/header';
import LazyImg from '../../_components/lazy-img.astro';
import {
getCraftRecords,
getSkillModeIndex,
getSkillRecords,
getSkillScenarioIndex,
getSystemCategoryIndex,
getSystemRecords,
getTemplateRecords,
tally,
} from '../../_lib/catalog';
import { getCraftRecords } from '../../_lib/catalog';
import {
PREFIXED_LOCALES,
getCopy,
@ -23,31 +13,17 @@ import {
import '../../globals.css';
import '../../sub-pages.css';
// Localized routing only generates listing/index pages. Detail pages
// (individual skills, posts, templates, …) stay at canonical English
// URLs to keep the static build bounded; the localized chrome links
// straight to those canonical detail URLs.
// Localized routing only generates the `craft` and `blog` listing pages.
// Detail pages (individual posts, craft items, …) stay at canonical
// English URLs to keep the static build bounded; the localized chrome
// links straight to those canonical detail URLs.
export async function getStaticPaths() {
const skillModes = await getSkillModeIndex();
const skillScenarios = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const paths = [
'skills',
'systems',
'craft',
'templates',
'blog',
// Plugins library is generated via short-code wrappers under
// `app/pages/[locale]/plugins/` (mirroring the `[locale]/skills/`,
// `[locale]/systems/`, etc. pattern), so it does NOT participate
// in this long-code catch-all. Both surfaces co-exist in `out/`
// because `_redirects` maps `/zh-CN/*` → `/zh/*` for the long-form
// routes; plugins lives under the short-form path only.
...skillModes.map((item) => `skills/mode/${item.slug}`),
...skillScenarios.map((item) => `skills/scenario/${item.slug}`),
...systemCategories.map((item) => `systems/category/${item.slug}`),
];
// The skills / systems / templates catalogs moved under `/plugins/*`.
// Their old localized listings are now 301'd by `public/_redirects`,
// so this catch-all only renders the localized `craft` and `blog`
// listings. Plugins itself is generated via short-code wrappers under
// `app/pages/[locale]/plugins/`, so it does NOT participate here.
const paths = ['craft', 'blog'];
return PREFIXED_LOCALES.flatMap((locale) =>
paths.map((path) => ({
@ -62,34 +38,18 @@ const copy = getCopy(locale);
const pathParam = Astro.params.path ?? '';
const segments = pathParam.split('/').filter(Boolean);
const [skills, systems, craft, templates, posts] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
const [craft, posts] = await Promise.all([
getCraftRecords(),
getTemplateRecords(),
getCollection('blog'),
]);
// All cross-locale subpage links resolve to canonical (English) URLs.
const href = (path: string) => path;
const titleSuffix = 'Open Design';
const routeRoot = segments[0] ?? '';
const routeSecond = segments[1] ?? '';
const routeThird = segments[2] ?? '';
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const modeTags = await getSkillModeIndex();
const scenarioTags = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const platformTally = tally(skills.map((skill) => skill.platform).filter((item): item is string => Boolean(item)));
const pageTitle = routeRoot === 'skills'
? `${copy.skillsTitle} — ${skills.length} | ${titleSuffix}`
: routeRoot === 'systems'
? `${copy.systemsTitle} — ${systems.length} | ${titleSuffix}`
: routeRoot === 'templates'
? `${copy.templatesTitle} — ${templates.length} | ${titleSuffix}`
: routeRoot === 'craft'
const pageTitle = routeRoot === 'craft'
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
: `${copy.blog} — ${titleSuffix}`;
@ -123,61 +83,6 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'skills' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 01</span>
<h1 class='display'><em>{copy.skillsTitle}</em> — {skills.length} composable design capabilities<span class='dot'>.</span></h1>
<p class='lead'>Each skill is a folder with one <code>SKILL.md</code>. Drop it in, restart the daemon, and the picker shows it.</p>
</header>
{routeSecond === '' && (
<section class='filter-strip' aria-label='Skill filters'>
<div class='filter-group'>
<span class='filter-label'>{copy.mode}</span>
<ul>{modeTags.map((tag) => <li><a class='chip chip-link' href={href(`/skills/mode/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.scenario}</span>
<ul>{scenarioTags.slice(0, 12).map((tag) => <li><a class='chip chip-link' href={href(`/skills/scenario/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.platform}</span>
<ul>{platformTally.map(([key, count]) => <li><span class='chip'>{key}<span class='chip-num'>{count}</span></span></li>)}</ul>
</div>
</section>
)}
<section class='catalog-grid catalog-grid-skills'>
<ol>
{skills
.filter((skill) => routeSecond === 'mode' ? skill.mode === routeThird : routeSecond === 'scenario' ? skill.scenario === routeThird : true)
.map((skill, index) => (
<li class='catalog-row'>
<a href={href(`/skills/${skill.slug}/`)}>
<span class='row-index'>{String(index + 1).padStart(2, '0')}</span>
<span class='row-body'><span class='row-name'>{skill.name}</span><span class='row-desc'>{skill.description}</span></span>
{skill.mode && <span class='meta-tag'>{skill.mode}</span>}
</a>
</li>
))}
</ol>
</section>
</>
)}
{routeRoot === 'systems' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 02</span>
<h1 class='display'><em>{copy.systemsTitle}</em> — {systems.length} portable visual systems<span class='dot'>.</span></h1>
<p class='lead'>Each system is a single <code>DESIGN.md</code> token spec that keeps colors, type, spacing, and components consistent.</p>
</header>
{routeSecond === '' && <section class='filter-strip'><div class='filter-group'><span class='filter-label'>{copy.category}</span><ul>{systemCategories.map((tag) => <li><a class='chip chip-link' href={href(`/systems/category/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul></div></section>}
<section class='catalog-grid systems-grid'>
<ul>{systems.filter((system) => routeSecond === 'category' ? system.category === routeThird : true).map((system) => <li class='system-card'><a href={href(`/systems/${system.slug}/`)}><span class='system-name'>{system.name}</span><p>{system.tagline}</p><span class='meta-tag'>{system.category}</span></a></li>)}</ul>
</section>
</>
)}
{routeRoot === 'craft' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
@ -185,11 +90,4 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'templates' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 04</span><h1 class='display'><em>{copy.templatesTitle}</em> — {templates.length} ready-to-fork artifacts<span class='dot'>.</span></h1><p class='lead'>Pre-wired artifact bundles with examples, visual language, and agent instructions.</p></header>
<section class='template-grid'><ul>{templates.map((template, index) => <li class='template-card'><a href={href(template.detailHref)}>{template.previewUrl && <span class='template-thumb'><LazyImg src={template.previewUrl} alt='' loading={index < 4 ? 'eager' : 'precise'} /></span>}<span class='template-name'>{template.name}</span><p class='template-summary'>{template.summary}</p></a></li>)}</ul></section>
</>
)}
</Layout>

View file

@ -1,19 +0,0 @@
---
import SkillPage, {
getStaticPaths as getSkillStaticPaths,
} from '../../skills/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillPage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import SkillsPage from '../../skills/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<SkillsPage />

View file

@ -1,19 +0,0 @@
---
import SkillModePage, {
getStaticPaths as getSkillModeStaticPaths,
} from '../../../skills/mode/[mode].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillModeStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillModePage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SkillScenarioPage, {
getStaticPaths as getSkillScenarioStaticPaths,
} from '../../../skills/scenario/[scenario].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillScenarioStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillScenarioPage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SystemPage, {
getStaticPaths as getSystemStaticPaths,
} from '../../systems/[slug].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSystemStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SystemPage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SystemCategoryPage, {
getStaticPaths as getSystemCategoryStaticPaths,
} from '../../../systems/category/[category].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSystemCategoryStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SystemCategoryPage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import SystemsPage from '../../systems/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<SystemsPage />

View file

@ -1,19 +0,0 @@
---
import TemplatePage, {
getStaticPaths as getTemplateStaticPaths,
} from '../../templates/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getTemplateStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<TemplatePage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import TemplatesPage from '../../templates/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<TemplatesPage />

View file

@ -207,8 +207,8 @@ const jsonLd = [
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>
</section>

View file

@ -81,7 +81,7 @@ const bottomCta =
? {
title: ui.blog.cta.skillsTitle,
body: ui.blog.cta.skillsBody,
href: '/skills/',
href: '/plugins/skills/',
label: ui.blog.cta.skillsLabel,
external: false,
}

View file

@ -1058,7 +1058,7 @@ pnpm -F @html-anything/next dev
</p>
<p>
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
<a class="ha-btn" href={href('/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={href('/plugins/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
</p>
</section>

View file

@ -45,9 +45,9 @@ const sources = [
{ ...page.sources[4], href: DISCORD },
{ ...page.sources[5], href: DOCS },
{ ...page.sources[6], href: REPO_LICENSE },
{ ...page.sources[7], href: href('/skills/') },
{ ...page.sources[8], href: href('/systems/') },
{ ...page.sources[9], href: href('/templates/') },
{ ...page.sources[7], href: href('/plugins/skills/') },
{ ...page.sources[8], href: href('/plugins/systems/') },
{ ...page.sources[9], href: href('/plugins/templates/') },
];
const jsonLd = [
@ -140,8 +140,8 @@ const jsonLd = [
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
</ul>
</section>
</article>

View file

@ -12,10 +12,7 @@
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemRecords,
getSystemCategoryIndex,
} from '../../../_lib/catalog';
import { getSystemRecords } from '../../../_lib/catalog';
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
@ -24,7 +21,6 @@ const ui = getLandingUiCopy(locale);
const pcopy = getPluginsCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
const description = pcopy.systemsLead;
@ -54,21 +50,6 @@ const jsonLd = {
<p class="lead">{pcopy.systemsLead}</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}

View file

@ -142,8 +142,8 @@ const jsonLd = [
<section class="info-section" id="next">
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>

View file

@ -1,472 +0,0 @@
---
/*
* /skills/<slug>/ — a detail page per skill.
*
* Two flavours render slightly differently:
* - `template` skills get a click-to-expand iframe of their
* `example.html` demo and stay deliberately brief — the demo is the
* content, the README is one click away on GitHub.
* - `instruction` skills (no runnable demo) instead render the full
* SKILL.md body inline, so the page reads like a brief: what the
* skill does, when it triggers, how to use it. Otherwise the page
* would be a one-line description and a row of CTAs.
*/
import { getEntry, render } from 'astro:content';
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/*
* Localized share-copy template, keyed by landing locale. The brand
* keyword "open-source Claude Design alternative" stays in English
* because that's the canonical search query Google associates with
* the domain — translating it would split the entity claim. The
* surrounding sentence ("I'm using X from @opendesignai") translates
* per locale so the message reads as one coherent voice instead of
* mixing two scripts in a single share post.
*
* `{name}` and `{description}` are interpolated at render time.
* `{url}` is replaced with the canonical detail-page URL.
*/
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
✨ Local-first · BYOK · your agent does the design.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
✨ Local-first · BYOK · dein Agent designt.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
✨ Local-first · BYOK · votre agent fait le design.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Локально · BYOK · агент сам делает дизайн.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Local-first · BYOK · tu agente diseña.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
✨ Local-first · BYOK · seu agente faz o design.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
✨ Local-first · BYOK · il tuo agente progetta.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Local-first · BYOK · twój agent projektuje.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
✨ Local-first · BYOK · agent kamu yang nge-desain.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
✨ Local-first · BYOK · jouw agent ontwerpt.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ محلي أولًا · BYOK · وكيلك يصمّم.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Local-first · BYOK · ajanın tasarlıyor.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Local-first · BYOK · ваш агент робить дизайн.
→ ${url}`,
};
export async function getStaticPaths() {
const skills = await getSkillRecords();
return skills.map((skill) => ({
params: { slug: skill.slug },
props: { skill, all: skills },
}));
}
interface Props {
skill: SkillRecord;
all: ReadonlyArray<SkillRecord>;
}
const { skill: routeSkill, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
const title = ui.catalog.skills.detailTitle(skill.name);
const description = skill.description.length > 0
? skill.description
: ui.catalog.skills.detailFallbackDescription(skill.name);
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: skill.name,
description,
url: skillUrl,
});
// Share-dialog UI strings localized inline. Keeping them next to the
// page that uses them avoids growing the global UI bundle for what's
// effectively four short labels per locale.
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const related = all
.filter((s) => s.slug !== skill.slug)
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
.slice(0, 4);
/*
* Instruction skills don't have a runnable demo to iframe — to avoid
* a near-empty detail page, render the SKILL.md prose inline so the
* page reads like a brief. Template skills keep the page deliberately
* brief because their demo is the content; their full SKILL.md is one
* "Find on GitHub" click away.
*
* Astro 6 exposes the markdown pipeline through a top-level
* `render(entry)` helper rather than the legacy `entry.render()`
* method. The output (heading anchors, smart-typography, GFM
* tables) styles cleanly with the existing `.detail-md` rules.
*/
const skillEntry =
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.skills.detailLabel, item: new URL('/skills/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: skill.name,
description,
codeRepository: skill.source,
programmingLanguage: 'Markdown',
keywords: skill.triggers.join(', '),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
},
];
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.skills.detailLabel}
{typeof skill.featured === 'number' && (
<span class="ix">{ui.catalog.skills.featuredNumber(String(skill.featured).padStart(2, '0'))}</span>
)}
</span>
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
<p class="lead">{description}</p>
<div class="detail-actions">
{/*
Two primary CTAs. "Use this skill" v1 sends users to the OD
desktop release page — install the app first, then run the
skill. Routing here rather than to /quickstart/ keeps the
flow concrete (download a binary now) instead of asking
users to read an install doc. Once the desktop client
exposes a registered URL scheme, this anchor flips to a
JS-driven `od://skill/<slug>` try + fallback without
changing the page surface.
*/}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this skill →
</a>
<a
class="btn btn-ghost"
href={skill.source}
target="_blank"
rel="noopener"
>
Find on GitHub →
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
{ui.catalog.skills.upstream}
</a>
)}
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`skill:${skill.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{skill.kind === 'template' && skill.previewUrl && (
<figure class="detail-preview">
{/*
Click-to-expand interactive preview. Only template-kind skills
ship a runnable example.html, so this block is gated on kind
rather than just `previewUrl` — instruction skills now have a
synthesized cover thumbnail too, but no iframe target. The
thumb is the summary of a `<details>` element: clicking opens
the live iframe, replacing the thumb with the canonical
`<slug>/example.html` rendered inside a sandboxed frame.
*/}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
<LazyImg
src={skill.previewUrl}
alt={`${skill.name} example output`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={`/skills/${skill.slug}/example.html`}
title={`${skill.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={`/skills/${skill.slug}/example.html`}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>
{ui.catalog.skills.previewCaption(skill.slug)}
</figcaption>
</figure>
)}
{/*
Share modal — opens a `<dialog>` containing the canonical share
copy (with the brand keyword "open-source Claude Design
alternative" baked in), a one-click "Copy" button, and a row of
platform jump buttons. Each platform button just opens the
vendor's compose URL — the user pastes the already-copied text.
This works around a real cross-platform pain point: LinkedIn /
Facebook ignore pre-fill `text` params, X has length limits that
truncate Chinese content unpredictably, and Reddit's title param
survives but title-only is a weak signal. Copy-then-paste is
uniformly reliable.
The trigger sits inside `.detail-actions` instead of as a
separate row below `.detail-meta` so it has visual weight equal
to the primary CTAs. Joey called this out specifically.
*/}
<dialog
class="detail-share-dialog"
data-share-dialog={`skill:${skill.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={skillUrl}
>
{shareUi.copyLink}
</button>
</div>
{/*
Platform jump buttons — official brand logos rendered as
inline SVG (no third-party icon font, no client JS). Each
opens the vendor's compose surface in a new tab; the user
pastes the already-copied text. Email channel was dropped
per Joey's revision; the four channels here cover the
highest-value SEO + virality surfaces.
*/}
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{skill.modeLabel ?? skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{skill.platformLabel ?? skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>{ui.catalog.systems.category}</dt>
<dd>{skill.categoryLabel ?? skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.triggers}</h2>
<p class="block-lead">
{ui.catalog.skills.triggersLead}
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>{ui.catalog.skills.examplePrompt}</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{SkillBody && (
<section class="detail-block detail-md">
<h2>About this skill</h2>
<SkillBody />
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.related}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/skills/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,133 +0,0 @@
---
/*
* /skills/ — index of every shippable skill in the repo.
*
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
* Collections so adding a skill anywhere in the monorepo
* automatically surfaces here on the next build.
*/
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import SkillRow from '../../_components/skill-row.astro';
import {
getSkillRecords,
getSkillModeIndex,
getSkillScenarioIndex,
tally,
} from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const skills = await getSkillRecords(locale);
const modeTags = await getSkillModeIndex(locale);
const scenarioTags = await getSkillScenarioIndex(locale);
const platformTally = tally(
skills.map((s) => s.platformLabel).filter((p): p is string => Boolean(p)),
);
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
const title = ui.catalog.skills.title(skills.length);
const description = ui.catalog.skills.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/skills/', Astro.site).toString(),
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: Astro.site?.toString(),
},
numberOfItems: skills.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.heading(skills.length)}
</h1>
<p class="lead">
{ui.catalog.skills.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.skills.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.mode}</span>
<ul>
{modeTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/mode/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.scenario}</span>
<ul>
{scenarioTags.slice(0, 12).map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/scenario/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
{platformTally.length > 0 && (
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.platform}</span>
<ul>
{platformTally.map(([key, count]) => (
<li>
<span class="chip">
{key}<span class="chip-num">{count}</span>
</span>
</li>
))}
</ul>
</div>
)}
</section>
{featured.length > 0 && (
<section class="featured-strip" aria-labelledby="featured-skills">
<h2 id="featured-skills" class="strip-title">{ui.catalog.skills.featured}</h2>
<ul class="featured-grid">
{featured.map((s, i) => (
<li class="featured-card">
<a href={href(`/skills/${s.slug}/`)}>
{s.previewUrl ? (
<span class="featured-thumb">
<LazyImg src={s.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
)}
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
<span class="featured-name">{s.name}</span>
<p>{s.description}</p>
{s.modeLabel && <span class="meta-tag">{s.modeLabel}</span>}
</a>
</li>
))}
</ul>
</section>
)}
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,78 +0,0 @@
---
/*
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
* (deck, prototype, template, image, video, audio, design-system, utility).
*
* One static page per distinct `od.mode` value. Mode is the strongest
* mental-model facet ("I want a deck-builder") so this is the primary
* faceted view; scenario/category live alongside.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillModeIndex,
getSkillsForMode,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillModeIndex();
return tags.map((tag) => ({
params: { mode: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForMode(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.modeDescription(heading, records.length);
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.mode}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.modeHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.modeLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills(tag.count)}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,77 +0,0 @@
---
/*
* /skills/scenario/<slug>/ — every skill targeting a given use-case
* scenario (marketing, engineering, design, research, ...).
*
* Mirrors the mode page but facets on `od.scenario`. One page per
* distinct scenario value found across all SKILL.md files.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillScenarioIndex,
getSkillsForScenario,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillScenarioIndex();
return tags.map((tag) => ({
params: { scenario: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForScenario(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.scenarioDescription(heading, records.length);
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.scenario}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.scenarioHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.scenarioLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills()}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,126 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
export async function getStaticPaths() {
const systems = await getSystemRecords();
return systems.map((system) => ({
params: { slug: system.slug },
props: { system, all: systems },
}));
}
interface Props {
system: SystemRecord;
all: ReadonlyArray<SystemRecord>;
}
const { system: routeSystem, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSystemRecords(locale);
const system = all.find((item) => item.slug === routeSystem.slug) ?? routeSystem;
const title = ui.catalog.systems.detailTitle(system.name);
const description = system.tagline
? `${system.name} (${system.categoryLabel}) — ${system.tagline}`
: ui.catalog.systems.detailFallbackDescription(system.name, system.categoryLabel);
const related = all
.filter((s) => s.slug !== system.slug && s.category === system.category)
.slice(0, 4);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.systems.detailLabel, item: new URL('/systems/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: system.name,
description,
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
genre: system.categoryLabel,
},
];
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span>/</span>
<span aria-current="page">{system.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.systems.detailLabel}
<span class="ix">· {system.categoryLabel}</span>
</span>
<h1 class="display">{system.name}<span class="dot">.</span></h1>
{system.tagline && <p class="lead">{system.tagline}</p>}
<div class="detail-actions">
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
{ui.catalog.systems.viewOnGithub}
</a>
</div>
</header>
{system.palette.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.paletteSample}</h2>
<p class="block-lead">
{ui.catalog.systems.paletteLead(system.palette.length)}
</p>
<div class="palette-row">
{system.palette.map((hex) => (
<div class="palette-cell">
<span class="swatch" style={`background:${hex}`} />
<code>{hex}</code>
</div>
))}
</div>
</section>
)}
{system.atmosphere && (
<section class="detail-block">
<h2>{ui.catalog.systems.visualTheme}</h2>
<p class="atmosphere">{system.atmosphere}</p>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.related(system.categoryLabel)}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/systems/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.tagline}</span>
<div class="system-swatches" aria-hidden="true">
{r.palette.slice(0, 4).map((hex) => (
<span class="swatch" style={`background:${hex}`} />
))}
</div>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,74 +0,0 @@
---
/*
* /systems/category/<slug>/ — every design system grouped by category
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemCategoryIndex,
getSystemsForCategory,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSystemCategoryIndex();
return tags.map((tag) => ({
params: { category: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSystemsForCategory(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.systems.categoryHeading(heading, records.length);
const description = ui.catalog.systems.categoryDescription(heading, records.length);
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.systems.category}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.categoryHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.systems.categoryLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/systems/')}>{ui.catalog.systems.allSystems}</a>
</p>
</header>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{records.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,61 +0,0 @@
---
/*
* /systems/ — index of every portable design system in the repo.
*/
import Layout from '../../_components/sub-page-layout.astro';
import SystemCard from '../../_components/system-card.astro';
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = ui.catalog.systems.title(systems.length);
const description = ui.catalog.systems.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/systems/', Astro.site).toString(),
numberOfItems: systems.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.heading(systems.length)}
</h1>
<p class="lead">
{ui.catalog.systems.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,356 +0,0 @@
---
/*
* /templates/<slug>/ — detail page for renderable design templates and
* legacy Live Artifact template bundles.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/* See pages/skills/[slug]/index.astro for the rationale on why these
* tables live inline rather than in the global UI bundle. Same shape,
* just keyed for the templates surface. */
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
✨ Templates as files, not vendor docs. Fork → swap → ship.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
✨ Template come file, non come DB vendor. Fork → swap → ship.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
→ ${url}`,
};
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template: routeTemplate } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
const template =
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
const title = ui.catalog.templates.detailTitle(template.name);
const description = template.summary;
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: template.name,
description: template.summary,
url: templateUrl,
});
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const originLabel =
template.origin === 'live-artifact'
? ui.catalog.templates.liveArtifact
: ui.catalog.templates.skillTemplate;
const files =
template.origin === 'live-artifact'
? [
['template.html', ui.catalog.templates.renderer],
['data.json', ui.catalog.templates.seedData],
['README.md', ui.catalog.templates.readme],
]
: [
['SKILL.md', ui.catalog.skills.detailLabel],
['example.html', ui.catalog.templates.previewCaption],
['assets/', ui.catalog.templates.detailLabel],
['references/', ui.catalog.craft.detailLabel],
];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.templates.detailLabel, item: new URL('/templates/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
],
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.templates.detailLabel}
<span class="ix">· {originLabel}</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
{(template.mode || template.platform || template.scenario) && (
<dl class="detail-meta">
{template.mode && (
<>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{template.modeLabel ?? template.mode}</dd>
</>
)}
{template.platform && (
<>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{template.platformLabel ?? template.platform}</dd>
</>
)}
{template.scenario && (
<>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{template.scenarioLabel ?? template.scenario}</dd>
</>
)}
</dl>
)}
<div class="detail-actions">
{/* Two CTAs matching skills/[slug]: "Use this template" sends
users to the OD desktop release page (install first, then
use the template); "Find on GitHub" deep-links to the
source folder. See skills/[slug].astro for the broader
rationale on the release-page pivot. */}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this template →
</a>
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
Find on GitHub →
</a>
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`template:${template.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
{/* Click-to-expand: thumb is the summary; clicking opens the
live iframe rendering the canonical artifact. Skill-template
origin → /skills/<slug>/example.html; live-artifact origin
→ /templates/<slug>/preview.html. */}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
<LazyImg
src={template.previewUrl}
alt={`${template.name} preview`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
title={`${template.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
</figure>
)}
{/* Share modal — same shape as skills/[slug]; see that file for the
copy-then-paste rationale and SEO keyword choice. */}
<dialog
class="detail-share-dialog"
data-share-dialog={`template:${template.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={templateUrl}
>
{shareUi.copyLink}
</button>
</div>
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<section class="detail-block">
<h2>{ui.catalog.templates.whatsInside}</h2>
<p class="block-lead">
{ui.catalog.templates.whatsInsideLead}
</p>
<ul class="trigger-list">
{files.map(([name, copy]) => (
<li><code>{name}</code> — {copy}</li>
))}
</ul>
</section>
</article>
</Layout>

View file

@ -1,63 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import { getTemplateRecords } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const templates = await getTemplateRecords(locale);
const title = ui.catalog.templates.title(templates.length);
const description = ui.catalog.templates.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/templates/', Astro.site).toString(),
numberOfItems: templates.length,
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.templates.label}</span>
<h1 class="display">
{ui.catalog.templates.heading(templates.length)}
</h1>
<p class="lead">
{ui.catalog.templates.lead}
</p>
</header>
<section class="template-grid" aria-label={ui.catalog.templates.allAria}>
<ul>
{templates.map((t, i) => (
<li class="template-card">
<a href={href(t.detailHref)}>
{t.previewUrl ? (
<span class="template-thumb">
<LazyImg src={t.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
)}
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
{t.origin === 'live-artifact' ? ui.catalog.templates.liveArtifact : (t.modeLabel ?? ui.catalog.templates.skillTemplate)}
</span>
<span class="template-name">{t.name}</span>
<p class="template-summary">{t.summary}</p>
{(t.platform || t.scenario) && (
<span class="template-meta-line">
{[t.platformLabel ?? t.platform, t.scenarioLabel ?? t.scenario].filter(Boolean).join(' · ')}
</span>
)}
</a>
</li>
))}
</ul>
</section>
</Layout>

View file

@ -257,11 +257,11 @@ export default defineConfig({
item.priority = 0.9;
item.changefreq = changefreq.weekly;
} else if (
path === '/skills/' ||
path === '/systems/' ||
path === '/templates/' ||
path === '/craft/' ||
path === '/plugins/'
path === '/plugins/' ||
path === '/plugins/skills/' ||
path === '/plugins/systems/' ||
path === '/plugins/templates/'
) {
item.priority = 0.7;
item.changefreq = changefreq.weekly;

View file

@ -34,3 +34,85 @@
/fa/plugins/* /plugins/:splat 301
/hu/plugins/* /plugins/:splat 301
/th/plugins/* /plugins/:splat 301
# ─────────────────────────────────────────────────────────────────────
# Catalog migration: legacy /skills /systems /templates -> /plugins/*
# The old Astro generators were removed; these 301s preserve inbound
# links and SEO equity. Cloudflare matches first rule wins, so order is:
# faceted/specific -> detail prefixes -> bare index -> locale variants.
# trailingSlash:'always', so every source and target ends in '/'.
# ─────────────────────────────────────────────────────────────────────
# Faceted pages have no new equivalent -> degrade to the section landing.
/skills/mode/* /plugins/skills/ 301
/skills/scenario/* /plugins/skills/ 301
/systems/category/* /plugins/systems/ 301
# Systems detail: design-system-<folder> is the uniform new slug.
# These 8 folders have no new detail page -> degrade (must precede splat).
/systems/cisco/ /plugins/systems/ 301
/systems/hud/ /plugins/systems/ 301
/systems/loom/ /plugins/systems/ 301
/systems/perplexity/ /plugins/systems/ 301
/systems/slack/ /plugins/systems/ 301
/systems/trading-terminal/ /plugins/systems/ 301
/systems/webex/ /plugins/systems/ 301
/systems/wechat/ /plugins/systems/ 301
/systems/* /plugins/design-system-:splat 301
# Templates detail: example-<folder> is the uniform new slug.
/templates/live-otd-operations-brief/ /plugins/templates/ 301
/templates/* /plugins/example-:splat 301
# Skills detail: only these 27 have a new artifact-template equivalent.
# 'replicate' collides with design-system-replicate -> force the section.
/skills/replicate/ /plugins/skills/ 301
/skills/article-magazine/ /plugins/example-article-magazine/ 301
/skills/card-twitter/ /plugins/example-card-twitter/ 301
/skills/card-xiaohongshu/ /plugins/example-card-xiaohongshu/ 301
/skills/data-report/ /plugins/example-data-report/ 301
/skills/deck-guizang-editorial/ /plugins/example-deck-guizang-editorial/ 301
/skills/deck-open-slide-canvas/ /plugins/example-deck-open-slide-canvas/ 301
/skills/deck-swiss-international/ /plugins/example-deck-swiss-international/ 301
/skills/design-brief/ /plugins/example-design-brief/ 301
/skills/doc-kami-parchment/ /plugins/example-doc-kami-parchment/ 301
/skills/frame-data-chart-nyt/ /plugins/example-frame-data-chart-nyt/ 301
/skills/frame-flowchart-sticky/ /plugins/example-frame-flowchart-sticky/ 301
/skills/frame-glitch-title/ /plugins/example-frame-glitch-title/ 301
/skills/frame-light-leak-cinema/ /plugins/example-frame-light-leak-cinema/ 301
/skills/frame-liquid-bg-hero/ /plugins/example-frame-liquid-bg-hero/ 301
/skills/frame-logo-outro/ /plugins/example-frame-logo-outro/ 301
/skills/frame-macos-notification/ /plugins/example-frame-macos-notification/ 301
/skills/hatch-pet/ /plugins/example-hatch-pet/ 301
/skills/mockup-device-3d/ /plugins/example-mockup-device-3d/ 301
/skills/poster-hero/ /plugins/example-poster-hero/ 301
/skills/ppt-keynote/ /plugins/example-ppt-keynote/ 301
/skills/pptx-html-fidelity-audit/ /plugins/example-pptx-html-fidelity-audit/ 301
/skills/resume-modern/ /plugins/example-resume-modern/ 301
/skills/social-reddit-card/ /plugins/example-social-reddit-card/ 301
/skills/social-spotify-card/ /plugins/example-social-spotify-card/ 301
/skills/social-x-post-card/ /plugins/example-social-x-post-card/ 301
/skills/vfx-text-cursor/ /plugins/example-vfx-text-cursor/ 301
/skills/video-hyperframes/ /plugins/example-video-hyperframes/ 301
# Remaining ~110 instruction-only skills have no detail page -> section.
/skills/* /plugins/skills/ 301
# Bare catalog index pages (least specific -> last).
/skills/ /plugins/skills/ 301
/systems/ /plugins/systems/ 301
/templates/ /plugins/templates/ 301
# Locale-prefixed variants (active LANDING_LOCALES minus en: zh zh-tw ja ko).
# Non-en pages are sitemap-excluded; degrade to the section (no detail precision).
/zh/skills/* /zh/plugins/skills/ 301
/zh/systems/* /zh/plugins/systems/ 301
/zh/templates/* /zh/plugins/templates/ 301
/zh-tw/skills/* /zh-tw/plugins/skills/ 301
/zh-tw/systems/* /zh-tw/plugins/systems/ 301
/zh-tw/templates/* /zh-tw/plugins/templates/ 301
/ja/skills/* /ja/plugins/skills/ 301
/ja/systems/* /ja/plugins/systems/ 301
/ja/templates/* /ja/plugins/templates/ 301
/ko/skills/* /ko/plugins/skills/ 301
/ko/systems/* /ko/plugins/systems/ 301
/ko/templates/* /ko/plugins/templates/ 301

View file

@ -1622,6 +1622,7 @@ function AppInner() {
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
mediaProvidersNotice={mediaProvidersNotice}
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onProjectsRefresh={refreshProjects}
onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache}

View file

@ -138,6 +138,48 @@ export function splitOnQuestionForms(input: string): FormSegment[] {
return out;
}
// First parseable form in a message, used to surface the active discovery
// form in the right-hand Questions tab instead of inline in the chat.
export function findFirstQuestionForm(
input: string,
): { form: QuestionForm; raw: string } | null {
for (const seg of splitOnQuestionForms(input)) {
if (seg.kind === 'form') return { form: seg.form, raw: seg.raw };
}
return null;
}
// Drop a trailing, not-yet-closed question-form block from streaming text so
// the chat doesn't flash raw `<question-form>{…` markup before the JSON
// finishes. Returns the visible text plus whether such an open block existed
// (which means a form is mid-generation).
export function stripTrailingOpenQuestionForm(
input: string,
): { text: string; hadOpenForm: boolean } {
let cursor = 0;
while (cursor < input.length) {
const slice = input.slice(cursor);
const m = OPEN_RE.exec(slice);
if (!m) break;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openStart = cursor + m.index;
const openEnd = openStart + m[0].length;
const closeIdx = findCloseTag(input, openEnd, closeTag);
if (closeIdx === -1) {
return { text: input.slice(0, openStart), hadOpenForm: true };
}
cursor = closeIdx + closeTag.length;
}
return { text: input, hadOpenForm: false };
}
// True when a question-form open tag is present but its close tag hasn't
// streamed in yet — i.e. the model is still generating the form.
export function hasUnterminatedQuestionForm(input: string): boolean {
return stripTrailingOpenQuestionForm(input).hadOpenForm;
}
function findCloseTag(input: string, from: number, closeTag: string): number {
const closeLower = closeTag.toLowerCase();
const tagLen = closeTag.length;
@ -181,38 +223,8 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
if (!rawQuestions) return null;
const questions: FormQuestion[] = [];
rawQuestions.forEach((q, i) => {
if (!q || typeof q !== 'object') return;
const qo = q as Record<string, unknown>;
const id =
typeof qo.id === 'string' && qo.id.trim().length > 0
? qo.id.trim()
: `q${i + 1}`;
const label = typeof qo.label === 'string' ? qo.label : id;
const type = normalizeType(qo.type);
const options = parseOptions(qo.options);
const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined;
const help = typeof qo.help === 'string' ? qo.help : undefined;
const required = qo.required === true;
const maxSelections =
typeof qo.maxSelections === 'number' &&
Number.isInteger(qo.maxSelections) &&
qo.maxSelections > 0
? qo.maxSelections
: undefined;
const cards = parseDirectionCards(qo.cards);
const defaultValue = parseDefaultValue(qo, options);
questions.push({
id,
label,
type,
...(options ? { options } : {}),
...(placeholder ? { placeholder } : {}),
...(help ? { help } : {}),
...(required ? { required } : {}),
...(defaultValue !== undefined ? { defaultValue } : {}),
...(maxSelections !== undefined && type === 'checkbox' ? { maxSelections } : {}),
...(cards ? { cards } : {}),
});
const mapped = mapRawQuestion(q, i);
if (mapped) questions.push(mapped);
});
if (questions.length === 0) return null;
const id = attrs.id ?? (typeof obj.id === 'string' ? obj.id : 'discovery');
@ -229,6 +241,136 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
};
}
function mapRawQuestion(q: unknown, index: number): FormQuestion | null {
if (!q || typeof q !== 'object') return null;
const qo = q as Record<string, unknown>;
const id =
typeof qo.id === 'string' && qo.id.trim().length > 0 ? qo.id.trim() : `q${index + 1}`;
const label = typeof qo.label === 'string' ? qo.label : id;
const type = normalizeType(qo.type);
const options = parseOptions(qo.options);
const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined;
const help = typeof qo.help === 'string' ? qo.help : undefined;
const required = qo.required === true;
const maxSelections =
typeof qo.maxSelections === 'number' &&
Number.isInteger(qo.maxSelections) &&
qo.maxSelections > 0
? qo.maxSelections
: undefined;
const cards = parseDirectionCards(qo.cards);
const defaultValue = parseDefaultValue(qo, options);
return {
id,
label,
type,
...(options ? { options } : {}),
...(placeholder ? { placeholder } : {}),
...(help ? { help } : {}),
...(required ? { required } : {}),
...(defaultValue !== undefined ? { defaultValue } : {}),
...(maxSelections !== undefined && type === 'checkbox' ? { maxSelections } : {}),
...(cards ? { cards } : {}),
};
}
/**
* Tolerant parser for a still-streaming `<question-form>` block. Unlike
* {@link tryParseForm} it does not require valid, complete JSON: it reads the
* title/id from the open tag's attrs (available the instant the tag streams in)
* and extracts however many *complete* question objects have arrived so far.
* This lets the Questions tab render a frame immediately and fill questions in
* progressively as the model streams them, instead of flashing a skeleton and
* then a finished table. Returns null only when no open tag is present.
*/
export function parsePartialQuestionForm(input: string): QuestionForm | null {
const m = OPEN_RE.exec(input);
if (!m) return null;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = m.index + m[0].length;
const attrs = parseAttrs(m[2] ?? '');
const closeIdx = findCloseTag(input, openEnd, closeTag);
const rawBody = closeIdx === -1 ? input.slice(openEnd) : input.slice(openEnd, closeIdx);
// Strip a leading fenced ```json wrapper; the closing fence may not have
// streamed in yet, so only the opening fence is removed.
const body = rawBody.replace(/^\s*```(?:json)?\s*/i, '');
const id = attrs.id ?? extractJsonStringField(body, 'id') ?? 'discovery';
const title =
attrs.title ?? extractJsonStringField(body, 'title') ?? 'A few quick questions';
const description = extractJsonStringField(body, 'description');
const questions = extractCompleteQuestions(body);
return {
id,
title,
questions,
...(description ? { description } : {}),
};
}
// Pull complete `{...}` question objects out of a partial `"questions": [ … ]`
// array, stopping at the first object whose closing brace hasn't streamed in.
function extractCompleteQuestions(body: string): FormQuestion[] {
const keyMatch = /"questions"\s*:\s*\[/.exec(body);
if (!keyMatch) return [];
const out: FormQuestion[] = [];
let i = keyMatch.index + keyMatch[0].length;
let index = 0;
while (i < body.length) {
while (i < body.length && /[\s,]/.test(body[i] as string)) i++;
if (i >= body.length || body[i] === ']') break;
if (body[i] !== '{') break;
const objStr = extractBalancedObject(body, i);
if (!objStr) break;
try {
const mapped = mapRawQuestion(JSON.parse(objStr), index);
if (mapped) out.push(mapped);
} catch {
break;
}
i += objStr.length;
index++;
}
return out;
}
// Return the substring for the balanced `{...}` object starting at `start`,
// or null if it never closes (string-aware so braces inside strings don't count).
function extractBalancedObject(s: string, start: number): string | null {
let depth = 0;
let inStr = false;
let esc = false;
for (let i = start; i < s.length; i++) {
const c = s[i] as string;
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) return s.slice(start, i + 1);
}
}
return null;
}
// Best-effort extraction of a top-level "field": "value" string from a partial
// JSON body — used for title/id/description before the full body parses.
function extractJsonStringField(body: string, field: string): string | undefined {
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
const m = re.exec(body);
if (!m) return undefined;
try {
return JSON.parse(`"${m[1]}"`) as string;
} catch {
return m[1];
}
}
function normalizeType(raw: unknown): QuestionType {
if (typeof raw !== 'string') return 'text';
const lower = raw.toLowerCase().trim();

View file

@ -30,6 +30,7 @@ import {
} from "@open-design/contracts/analytics";
import {
splitOnQuestionForms,
stripTrailingOpenQuestionForm,
type QuestionForm,
} from "../artifacts/question-form";
import { stripArtifact } from "../artifacts/strip";
@ -311,6 +312,10 @@ interface Props {
// Submit handler the form fires when the user picks answers — opaque
// to AssistantMessage; ProjectView wires it into onSend.
onSubmitForm?: (text: string) => void;
// Open the right-hand Questions tab. The active discovery form renders
// there (Claude-Design style) instead of inline; this assistant message
// shows a banner that focuses the tab on click.
onOpenQuestions?: () => void;
onContinueRemainingTasks?: (todos: TodoItem[]) => void;
onFeedback?: (change: ChatMessageFeedbackChange) => void;
suppressDirectionForms?: boolean;
@ -342,6 +347,7 @@ export function AssistantMessage({
errorCardOwnerId = null,
nextUserContent,
onSubmitForm,
onOpenQuestions,
onContinueRemainingTasks,
onFeedback,
suppressDirectionForms = false,
@ -540,6 +546,7 @@ export function AssistantMessage({
});
onSubmitForm?.(text);
}}
onOpenQuestions={onOpenQuestions}
onRequestOpenFile={onRequestOpenFile}
/>
);
@ -1668,6 +1675,7 @@ function ProseBlock({
locallySubmitted,
suppressDirectionForms,
onSubmitForm,
onOpenQuestions,
onRequestOpenFile,
}: {
text: string;
@ -1677,10 +1685,22 @@ function ProseBlock({
locallySubmitted: Set<string>;
suppressDirectionForms: boolean;
onSubmitForm: (formId: string, text: string) => void;
onOpenQuestions?: () => void;
onRequestOpenFile?: (name: string) => void;
}) {
const cleaned = useMemo(() => stripArtifact(text), [text]);
const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]);
// While the latest turn is still streaming a not-yet-closed question-form,
// drop the partial `<question-form>{…` markup from the prose so the chat
// doesn't flash raw JSON; we surface a banner for it instead. The actual
// form streams into the right-hand Questions tab.
const { text: visibleText, hadOpenForm } = useMemo(
() =>
isLastAssistant && streaming
? stripTrailingOpenQuestionForm(cleaned)
: { text: cleaned, hadOpenForm: false },
[cleaned, isLastAssistant, streaming],
);
const segments = useMemo(() => splitOnQuestionForms(visibleText), [visibleText]);
// Route relative file-link clicks (`template.html`, `subdir/hero.html`)
// through the workspace tab opener. Without this, Electron's window-open
// handler creates a new app window whose relative href can't resolve, and
@ -1749,17 +1769,40 @@ function ProseBlock({
key={seg.key}
form={seg.form}
isLastAssistant={isLastAssistant}
streaming={streaming}
nextUserContent={nextUserContent}
locallySubmitted={locallySubmitted}
onSubmitForm={onSubmitForm}
onOpenQuestions={onOpenQuestions}
/>
);
})}
{hadOpenForm ? <QuestionsBanner onOpen={onOpenQuestions} /> : null}
</div>
);
}
// Chat-side banner that points to the right-hand Questions tab where the
// active discovery form lives. Clicking it focuses that tab.
function QuestionsBanner({ onOpen }: { onOpen?: () => void }) {
const t = useT();
return (
<button
type="button"
className="questions-banner"
data-testid="questions-banner"
onClick={() => onOpen?.()}
>
<span className="questions-banner-icon" aria-hidden>
<Icon name="help-circle" size={15} />
</span>
<span className="questions-banner-label">{t("questions.banner")}</span>
<span className="questions-banner-cta" aria-hidden>
<Icon name="chevron-right" size={14} />
</span>
</button>
);
}
function isDirectionForm(form: QuestionForm): boolean {
if (form.id.toLowerCase() === "direction") return true;
if (form.title.toLowerCase().includes("visual direction")) return true;
@ -1769,17 +1812,17 @@ function isDirectionForm(form: QuestionForm): boolean {
function FormBlock({
form,
isLastAssistant,
streaming,
nextUserContent,
locallySubmitted,
onSubmitForm,
onOpenQuestions,
}: {
form: QuestionForm;
isLastAssistant: boolean;
streaming: boolean;
nextUserContent?: string;
locallySubmitted: Set<string>;
onSubmitForm: (formId: string, text: string) => void;
onOpenQuestions?: () => void;
}) {
// Reconstruct prior answers from a follow-up user message so older
// forms in the scrollback render in their answered state.
@ -1788,15 +1831,18 @@ function FormBlock({
return parseSubmittedAnswers(form, nextUserContent);
}, [form, nextUserContent]);
const wasSubmittedLocally = locallySubmitted.has(form.id);
const interactive =
isLastAssistant &&
!streaming &&
!submittedFromHistory &&
!wasSubmittedLocally;
// The live, still-unanswered form lives in the right-hand Questions tab —
// even mid-stream. In chat we only show a banner that focuses it, so the
// left side never renders the form itself. Answered / historical forms stay
// inline so the scrollback reads naturally.
const showBanner = isLastAssistant && !submittedFromHistory && !wasSubmittedLocally;
if (showBanner) {
return <QuestionsBanner onOpen={onOpenQuestions} />;
}
return (
<QuestionFormView
form={form}
interactive={interactive}
interactive={false}
submittedAnswers={submittedFromHistory ?? undefined}
onSubmit={(text) => onSubmitForm(form.id, text)}
/>

View file

@ -47,6 +47,11 @@ import {
type InlineMentionEntity,
} from '../utils/inlineMentions';
import { isImeComposing } from '../utils/imeComposing';
import {
reconcileInsertions,
stripPluginInsertedTokens,
type TrackedInsertion,
} from '../utils/pluginInsertionTracking';
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@ -224,7 +229,23 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) {
const t = useT();
const analytics = useAnalytics();
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
const [draft, setDraft] = useState(
() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// Synchronous mirror of the latest committed draft value.
// `updateDraft` reads this as `prev` instead of relying on the
// closure `draft` (which only updates after re-render) or
// `setDraft((prev) => …)` (whose updater is double-invoked
// under React StrictMode and would mutate
// `pluginInsertedTokensRef` twice). The ref is updated
// synchronously by `updateDraft` before `setDraft`, so the
// next call sees a fresh `prev` even when React batches
// multiple updates within one tick. Initialized from the same
// source as the React state to keep the two in lockstep on
// first render. See `updateDraft` below and #2929 round 5.
const draftRef = useRef<string>(
initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// chat_panel page_view fires from ProjectView (which outlives
// conversation switches) so the event measures real chat-panel
@ -271,6 +292,77 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// or from the tools-menu "Details" affordance.
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
// Instance-aware tracking for `@<token>` mentions this surface
// inserted into the draft via the @-mention popover plugin-pick
// path (`insertPluginMention`). Each entry pins the precise
// start offset of `@`, so two `@Airbnb` mentions in the same
// draft (one composer-inserted, one user-authored) are
// distinguishable — the chip-clear strip removes only tracked
// instances (#2929 round 3). See utils/pluginInsertionTracking.ts
// for the diff/reconcile/strip primitives.
//
// Lifecycle invariants:
// - add: `insertPluginMention` pushes { token, start } using the
// `insertStart` returned by `replaceMentionWithText`
// - reconcile: `handleChange` runs LCP/LCS diff on each
// keystroke and shifts/drops entries whose offsets crossed
// the edit, plus revalidates surviving entries against the
// mention boundary so `@Airbnbify`-style corruption prunes
// - clear: `reset()` empties the array on send; `onCleared`
// strips by range and empties the array
//
// Tools-menu / details-modal applies route through
// `pluginsSectionRef.current.applyById` without writing to the
// draft, so the array stays empty for those surfaces and the
// post-clear strip is a no-op. Every draft mutation in this
// component goes through the `updateDraft` chokepoint, which
// runs `reconcileInsertions` against the prev → next diff. That
// includes typing, slash-command pick, file/MCP/connector
// insertion, skill chip remove, annotation append, imperative
// handle, post-send reset, and the on-cleared strip itself —
// so a tracked offset can never go stale relative to the draft
// and re-introduce the original #2881 orphan-mention symptom
// (#2929 round 4).
//
// Each entry carries the `pluginId` of the apply that produced
// it. When the active plugin changes (e.g. tools-menu `applyById`
// replaces plugin A with plugin B without writing to the draft),
// entries for the previous active plugin are dropped via
// `setActivePlugin`. Without that, clearing B's chip would still
// strip A's `@A` from the draft — silent user-text deletion in a
// supported replace-plugin flow (#2929 round 6).
const pluginInsertedTokensRef = useRef<TrackedInsertion[]>([]);
// The plugin id whose chip is currently mounted in PluginsSection's
// chip strip, or `null` after the strip clears or before any apply
// succeeds. Updated via `setActivePlugin`, which also drops any
// tracked entries whose `pluginId` does not match the new active
// — a no-op for `insertPluginMention` (the new entry it just
// pushed matches), critical for tools-menu / details-modal
// applies that arrive without an accompanying draft insertion.
const activePluginIdRef = useRef<string | null>(null);
// Monotonic counter that hands out unique `insertionId` strings to
// entries pushed by `insertPluginMention`. The id survives
// `reconcileInsertions` (utils/pluginInsertionTracking.ts forwards
// the field) so the in-flight handler's failure path can locate
// its own tracked entry even after intervening reconciles or
// `onCleared` mutations of the array (#2929 round 10 codex
// review). Plain ref counter is enough — the id only needs to be
// unique within a single composer instance and is never persisted.
const insertionIdSeqRef = useRef(0);
// Single chokepoint for setting the active plugin. Routes every
// `applyById` call so the tracker stays in lockstep with the
// chip strip's currently-mounted plugin.
function setActivePlugin(pluginId: string | null): void {
if (activePluginIdRef.current === pluginId) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current =
pluginInsertedTokensRef.current.filter(
(entry) => entry.pluginId === pluginId,
);
}
activePluginIdRef.current = pluginId;
}
// Consolidated "tools" popover — a single dropdown anchored to the
// leading sliders icon that hosts MCP / Import / Pet quick actions and
// a shortcut to open the full Settings dialog. Replaces the previous
@ -299,7 +391,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
useEffect(() => {
if (seededRef.current) return;
if (initialDraft && initialDraft !== draft) {
setDraft(initialDraft);
updateDraft(initialDraft);
seededRef.current = true;
} else if (initialDraft === undefined) {
seededRef.current = true;
@ -614,7 +706,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// command's canonical insertion text.
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setSlash(null);
requestAnimationFrame(() => {
ta.focus();
@ -658,7 +750,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const trimmed = draft.trim();
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
onOpenMcpSettings();
setDraft('');
updateDraft('');
return true;
}
@ -724,7 +816,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
return false;
}
}
setDraft('');
updateDraft('');
return true;
}
@ -732,7 +824,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
ref,
() => ({
setDraft: (text: string) => {
setDraft(text);
updateDraft(text);
seededRef.current = true;
requestAnimationFrame(() => {
const ta = textareaRef.current;
@ -743,7 +835,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
});
},
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
setDraft(text);
updateDraft(text);
setStaged(attachments);
setStagedVisualComments(commentAttachments);
setStagedSkills([]);
@ -768,8 +860,39 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
[]
);
// Single chokepoint for every draft mutation. Reconciles the
// tracked plugin-mention offsets against the prev → next diff so
// any setDraft path — typing, slash command, file/MCP/connector
// insertion, skill chip removal, annotation append, imperative
// handle, post-send reset, on-cleared strip — keeps
// `pluginInsertedTokensRef` in lockstep with the draft.
//
// Implementation note (#2929 round 5): the reconcile and the
// ref mutation happen *outside* the `setDraft` updater, using
// the synchronous `draftRef` mirror as `prev`. Putting them
// inside `setDraft((prev) => …)` would not be safe under
// React StrictMode, which double-invokes setState updaters in
// development to detect impurity — the second invocation
// would re-shift or re-drop already-reconciled entries,
// bringing back the #2881 orphan-mention symptom for every
// user keystroke in the dev build.
function updateDraft(next: string | ((prev: string) => string)): void {
const prev = draftRef.current;
const value = typeof next === 'function' ? next(prev) : next;
if (prev === value) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current = reconcileInsertions(
pluginInsertedTokensRef.current,
prev,
value,
);
}
draftRef.current = value;
setDraft(value);
}
function reset() {
setDraft("");
updateDraft("");
setStaged([]);
setStagedVisualComments([]);
setStagedSkills([]);
@ -778,6 +901,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
setUploadError(null);
setMention(null);
setSlash(null);
// Drop tracked plugin-mention insertions when the draft is wiped
// — otherwise a later chip clear would prune user-authored text
// that happened to share a label with a previously-applied
// plugin (#2929 round 2/3). Also clear the active-plugin id
// so the next applyById is treated as a fresh activation
// rather than a "same plugin re-apply" (#2929 round 6).
pluginInsertedTokensRef.current = [];
activePluginIdRef.current = null;
}
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
@ -829,7 +960,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// Also strip the matching `@<id>` token from the draft so the chip
// and the textarea stay in sync. We allow trailing whitespace to be
// collapsed too.
setDraft((d) =>
updateDraft((d) =>
d
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
.replace(/\s{2,}/g, ' '),
@ -1001,7 +1132,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}),
]);
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
if (detail.note) updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
ack({ ok: true });
@ -1022,7 +1153,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
}
ack({ ok: true });
@ -1118,7 +1249,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Goes through the `updateDraft` chokepoint so the
// plugin-mention offset reconcile runs on every keystroke,
// matching every other setDraft path for free.
updateDraft(value);
// Keep the staged-skill chips in sync with the draft. If the user
// hand-deletes an `@<id>` token from the textarea, the chip must
// disappear too — otherwise submit() would still forward that id in
@ -1165,7 +1299,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const after = draft.slice(cursor);
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
if (!staged.some((s) => s.path === filePath)) {
setStaged((s) => [
@ -1185,28 +1319,175 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
async function insertPluginMention(record: InstalledPluginRecord) {
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!inserted) return;
await pluginsSectionRef.current?.applyById(record.id, record);
// Snapshot tracker AND draft state before any mutation so we
// can roll back if `applyById` fails (#2929 round 7). Without
// this, an `/apply` 5xx leaves the draft holding a freshly
// inserted `@<token>` whose chip never mounted — a user
// clearing the previously-active plugin's chip would then
// strip the user-visible `@<token>` they just picked, even
// though that text is the only signal they have that
// anything happened.
const prevDraftValue = draftRef.current;
const prevEntries = pluginInsertedTokensRef.current;
const prevActiveId = activePluginIdRef.current;
const result = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!result) return;
// Capture the post-insert draft *snapshot* — the value the
// composer is in immediately after our optimistic write.
// Used as a sentinel during the rollback below: if the
// textarea is still in this state when `applyById` fails
// (no user keystrokes during the await), we can fully
// restore `prevDraftValue`. If the user typed during the
// await, the draft has moved past the snapshot and we MUST
// NOT clobber those edits with the stale `prevDraftValue`
// (#2929 round 8 — the textarea stays interactive while
// `/apply` is in flight, so this is a real prompt-data-loss
// path).
const postInsertDraft = draftRef.current;
// Track the precise start offset of the inserted `@` so the
// post-clear strip can excise exactly this instance, leaving
// any user-authored `@<sameLabel>` elsewhere in the draft
// untouched (#2929 round 3). Entry carries `pluginId` so a
// later replace-plugin flow can drop it cleanly (#2929 round 6),
// and an `insertionId` so this handler's failure path can
// locate the entry it pushed even after `reconcileInsertions`
// shifted offsets or `onCleared` mutated the array
// (#2929 round 10).
//
// Push the new entry but DO NOT yet drop entries from the
// previously-active plugin — that filter is committed only
// after `applyById` resolves successfully (#2929 round 9
// codex review). During the await, the chip strip still
// shows the previously-mounted plugin and the textarea is
// interactive: a user click on that chip's × must strip its
// tracked entries (not the optimistic `@<target>` we just
// pushed). `onCleared` filters by
// `pluginsSectionRef.current?.getActiveRecord()?.id` so a
// pending-window clear scopes to the actually-mounted
// plugin's tracked tokens.
const ourInsertionId = `i${++insertionIdSeqRef.current}`;
pluginInsertedTokensRef.current = [
...pluginInsertedTokensRef.current,
{
token: record.title,
start: result.insertStart,
pluginId: record.id,
insertionId: ourInsertionId,
},
];
const applyResult = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (!applyResult) {
// Two failure modes to disambiguate (#2929 round 10):
//
// (a) "no intervening clear" — the user neither cleared
// the previously-mounted chip nor anything else
// mutated the tracker beyond our push + reconciles
// from user keystrokes. `prevEntries` and
// `prevActiveId` are still the truth. We restore the
// tracker wholesale and restore the draft only if
// the user did not type during the await
// (round 7/8 path).
//
// (b) "intervening clear" — `onCleared` ran during the
// await for the previously-mounted chip, stripped
// its tokens from the draft, and nulled
// `activePluginIdRef`. Restoring `prevEntries`
// wholesale here would resurrect already-stripped
// entries with stale offsets, AND leave our
// optimistic `@<target>` orphaned in the draft (the
// original #2881 symptom recurring inside the
// failure window). Instead we surgically remove ONLY
// our own optimistic entry by `insertionId`, strip
// its `@<target>` from the draft, and leave
// everything `onCleared` did intact.
//
// Detection: `onCleared` always nulls
// `activePluginIdRef.current`; our deferred
// `setActivePlugin` never ran (we are in the failure
// branch). So `activePluginIdRef.current === null` while
// `prevActiveId !== null` is the smoking gun for an
// intervening clear. (If `prevActiveId` was already null,
// there was no chip to clear — no race possible.)
const intervenedClear =
activePluginIdRef.current === null && prevActiveId !== null;
if (intervenedClear) {
const cur = pluginInsertedTokensRef.current;
const idx = cur.findIndex(
(e) => e.insertionId === ourInsertionId,
);
if (idx >= 0) {
const ourEntry = cur[idx]!;
// Splice our entry out first so `updateDraft`'s
// internal `reconcileInsertions` operates on a tracker
// that already excludes it (the strip range overlaps
// the entry, which would drop it anyway, but splicing
// first keeps the invariant explicit and avoids
// depending on the reconcile drop edge case).
pluginInsertedTokensRef.current = [
...cur.slice(0, idx),
...cur.slice(idx + 1),
];
updateDraft((d) => stripPluginInsertedTokens(d, [ourEntry]));
}
// Don't touch `activePluginIdRef` — `onCleared` set it
// to null and that is the truth (no chip is mounted).
return;
}
// (a) round 7/8 path: no intervening clear.
pluginInsertedTokensRef.current = prevEntries;
activePluginIdRef.current = prevActiveId;
// Restore the draft only if no user keystrokes arrived
// during the await — overwriting newer edits with the
// stale pre-pick snapshot would be a worse bug than the
// leftover `@<token>` styled mention this branch leaves
// behind. The orphan stays as a styled mention but no
// future chip clear will touch it (tracker is empty for
// it now), and the user can edit it manually
// (#2929 round 8).
if (draftRef.current === postInsertDraft) {
setDraft(prevDraftValue);
draftRef.current = prevDraftValue;
}
return;
}
// Apply succeeded. Now commit the active-plugin switch —
// this drops any entries from the previously-active plugin
// (a no-op for the entry we just pushed since it matches
// `record.id`) and updates `activePluginIdRef`. Deferring
// until after the await means an `onCleared` triggered
// during the in-flight window saw the still-mounted plugin
// as the active one and stripped only that plugin's tokens
// (#2929 round 9).
setActivePlugin(record.id);
}
function replaceMentionWithText(text: string): boolean {
if (!mention) return false;
function replaceMentionWithText(
text: string,
): { insertStart: number } | null {
if (!mention) return null;
const ta = textareaRef.current;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
// The inserted text was appended onto `replaced`, so its first
// char (the `@`) sits at `replaced.length - text.length`.
const insertStart = replaced.length - text.length;
requestAnimationFrame(() => {
if (!ta) return;
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
return true;
return { insertStart };
}
function insertMcpMention(server: McpServerConfig) {
@ -1234,7 +1515,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function removeStaged(p: string) {
setStaged((s) => s.filter((a) => a.path !== p));
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
setDraft((current) => stripInlineMentionToken(current, p));
updateDraft((current) => stripInlineMentionToken(current, p));
}
function removeCommentAttachment(id: string) {
@ -1473,12 +1754,73 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
showRail={false}
onApplied={(brief) => {
// Use functional setState so stale closures from the @-mention
// flow (which awaits applyById after setDraft) still see the
// latest draft value before deciding whether to seed.
// flow (which awaits applyById after updateDraft) still see
// the latest draft value before deciding whether to seed.
if (typeof brief === 'string' && brief.length > 0) {
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
updateDraft((cur) => (cur.trim().length === 0 ? brief : cur));
}
}}
onCleared={() => {
// Removing the chip strip must drop the `@…` tokens
// this surface authored, otherwise the textarea is
// left holding orphaned mentions whose chips just
// unmounted (#2881). We strip *only* the tracked
// insertions (by precise start offset) so
// user-authored text that happens to share a label
// with a chip is preserved (#2929 round 3).
//
// The chip strip can clear while an `applyById` for
// a *different* plugin is mid-await — the @-popover
// optimistically writes `@<target>` and pushes a
// tracked entry synchronously, then awaits the
// apply (#2929 round 9 codex review). During that
// window the ref carries entries for both the
// still-mounted plugin (the chip the user is
// removing) and the in-flight target. Trusting the
// ref wholesale here would strip the optimistic
// `@<target>` and leave the unmounting plugin's
// `@<token>` orphaned — a recurrence of #2881 in a
// pending-apply window.
//
// PluginsSection only flips `activeRecord` after
// `applyPlugin` resolves successfully (see
// `PluginsSection.tsx`), so `getActiveRecord()` at
// the moment `onCleared` fires reports the plugin
// whose chip is currently being unmounted — exactly
// the one whose tracked entries we should strip.
// Filter to that id; entries for any in-flight
// replace target are left in place (the in-flight
// handler's success path will commit
// `setActivePlugin(target)` and drop them; its
// failure path will roll the tracker back).
const unmountingId =
pluginsSectionRef.current?.getActiveRecord()?.id ?? null;
const entries = pluginInsertedTokensRef.current;
if (entries.length > 0) {
const toStrip = unmountingId
? entries.filter((e) => e.pluginId === unmountingId)
: entries;
if (toStrip.length > 0) {
// `updateDraft` runs `reconcileInsertions`
// against the prev → next diff inside the
// chokepoint, so any in-flight target's entries
// get their offsets shifted to track the
// post-strip draft. We must re-read the ref
// *after* `updateDraft` returns instead of
// filtering the pre-strip `entries` snapshot,
// otherwise we would clobber the reconciled
// offsets and a later clear of the in-flight
// chip would no-op via `isInsertionStillValid`.
updateDraft((d) => stripPluginInsertedTokens(d, toStrip));
}
pluginInsertedTokensRef.current = unmountingId
? pluginInsertedTokensRef.current.filter(
(e) => e.pluginId !== unmountingId,
)
: [];
}
activePluginIdRef.current = null;
}}
onChipDetails={(item: ContextItem) => {
if (item.kind !== 'plugin') return;
const record = installedPlugins.find((p) => p.id === item.id);
@ -1700,11 +2042,32 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
plugins={pluginsForComposer}
activePluginId={pinnedPluginId}
onApply={async (record) => {
// Tools-menu apply: no draft write, so the
// tracked-insertion array gets no new
// entry. The active-plugin switch (which
// drops previously-tracked entries from a
// prior @-popover pick of a different
// plugin, #2929 round 6) is deferred until
// `applyById` resolves successfully so
// that an `onCleared` triggered during the
// in-flight window still sees the
// still-mounted plugin's entries and
// strips them correctly via the
// `getActiveRecord()` filter in
// `onCleared` (#2929 round 9).
//
// No synchronous mutation in this branch
// means no rollback snapshot is needed:
// the failure path is just an early return
// (#2929 round 7's snapshot was needed
// because `setActivePlugin` was eager).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setToolsOpen(false);
if (!result) return;
setActivePlugin(record.id);
setToolsOpen(false);
}}
onShowDetails={(record) => {
setDetailsRecord(record);
@ -1726,7 +2089,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = currentDraft.slice(0, cursor);
const after = currentDraft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1750,7 +2113,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1905,7 +2268,24 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={async (record) => {
await pluginsSectionRef.current?.applyById(record.id, record);
// Details-modal apply: same shape as tools-menu apply
// (no draft write). The active-plugin switch is
// deferred until `applyById` resolves successfully so
// that an `onCleared` triggered during the in-flight
// window still sees the still-mounted plugin's
// entries and strips them correctly (#2929 round 9).
//
// Modal closes regardless of apply outcome so the
// user is not stuck on the details view if `/apply`
// 5xx'd. Failure is a no-op: no synchronous mutation
// happened, so nothing to roll back (#2929 round 7's
// snapshot was needed because `setActivePlugin` was
// eager — round 9 made it lazy).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setActivePlugin(record.id);
setDetailsRecord(null);
}}
/>
@ -2278,6 +2658,11 @@ function ToolsPluginsPanel({
<button
type="button"
className="composer-tools-row-main"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 and the inserted token
// lands at the user's actual cursor position (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(p.id);
try {
@ -2369,6 +2754,10 @@ function ToolsMcpPanel({
type="button"
role="menuitem"
className="composer-tools-row"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
>
@ -2459,6 +2848,10 @@ function ToolsSkillsPanel({
type="button"
role="menuitem"
className={`composer-tools-row${active ? ' active' : ''}`}
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(skill.id);
try {

View file

@ -263,6 +263,8 @@ interface Props {
// Question-form submissions become a normal user message; the parent
// routes that text through onSend (no attachments).
onSubmitForm?: (text: string) => void;
// Focus the right-hand Questions tab from the chat banner.
onOpenQuestions?: () => void;
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
// Header "+" button — kicks off ProjectView's create-conversation flow.
@ -371,6 +373,7 @@ export function ChatPane({
forceStreamingMessageIds,
initialDraft,
onSubmitForm,
onOpenQuestions,
onContinueRemainingTasks,
onAssistantFeedback,
onNewConversation,
@ -1165,6 +1168,7 @@ export function ChatPane({
scrolledToFormRef.current = new Set();
onSubmitForm?.(text);
}}
onOpenQuestions={onOpenQuestions}
onContinueRemainingTasks={
m.id === lastAssistantId && onContinueRemainingTasks
? (todos) => onContinueRemainingTasks(m, todos)
@ -2014,6 +2018,16 @@ export function conversationMetaLabel(
t: TranslateFn,
): string {
const latestRun = conversation.latestRun;
if (
latestRun &&
(latestRun.status === 'succeeded' ||
latestRun.status === 'failed' ||
latestRun.status === 'canceled') &&
typeof conversation.totalDurationMs === 'number' &&
Number.isFinite(conversation.totalDurationMs)
) {
return formatDurationShort(conversation.totalDurationMs);
}
if (
latestRun &&
(latestRun.status === 'succeeded' ||

Some files were not shown because too many files have changed in this diff Show more