* feat(mocks): replay-based mock CLIs for opencode/claude/codex/deepseek/qwen/grok
Drops in a `mocks/` top-level dir that pretends to be the real agent
CLIs by streaming pre-recorded sessions in each CLI's native stdout
protocol. Zero LLM tokens.
## Use cases
- **E2E tests** in `apps/daemon/tests/` — exercise the full chat-server
pipeline against a known trace, assert UI events / artifacts.
- **Self-validation during dev** — iterate on `claude-stream.ts` /
`json-event-stream.ts` parser changes without burning provider budget.
- **Regression harness** — replay the same trace before and after a
charter / parser change; diff the daemon events the UI surfaces.
- **Demo / onboarding** — show what a 17-tool claude editing session
looks like end-to-end, offline.
## How
- 6 bash wrappers (`mocks/bin/`) shadow the real CLIs when PATH-overlaid.
- `mocks/mock-agent.mjs` reads `mocks/recordings/<trace>.jsonl`, picks
one via env var (`SYNCLO_EXPLORE_MOCK_TRACE` / `_POOL` /
`_BY_PROMPT_HASH`), streams the trace in the requested format.
- Each format renderer matches the EXACT JSON shape the OD daemon
parser expects, verified line-by-line against
`apps/daemon/src/{json-event-stream,claude-stream}.ts`:
| CLI | streamFormat | parser source |
| ------------------------- | ------------------------- | ------------------------------------------ |
| `opencode` | `json-event-stream` | `handleOpenCodeEvent` |
| `codex` | `json-event-stream` | `handleCodexEvent` |
| `claude` | `claude-stream-json` | `createClaudeStreamHandler` |
| `deepseek` `qwen` `grok` | `plain` | `server.ts` (raw stdout) |
## Quick start
```bash
export PATH="$PWD/mocks/bin:$PATH"
export SYNCLO_EXPLORE_MOCK_TRACE=04097377 # 8-char prefix OK
export SYNCLO_EXPLORE_MOCK_NO_DELAY=1
echo "any prompt" | opencode run
echo "any prompt" | claude -p --output-format=stream-json
echo "any prompt" | codex exec
```
The mock binary announces the picked trace id on stderr:
`[mock-opencode] picked 04097377… via fixed`.
Recording selection (env, in priority order):
- `SYNCLO_EXPLORE_MOCK_TRACE=<id>` — fixed (prefix OK)
- `SYNCLO_EXPLORE_MOCK_BY_PROMPT_HASH=1` + stdin prompt — `sha256(prompt) % N`
- `SYNCLO_EXPLORE_MOCK_POOL=<tag>` — random within `agent:claude` /
`skill:agent-browser` / `outcome:failed` / etc.
- (default) uniform random
- `SYNCLO_EXPLORE_MOCK_SEED=<str>` — reproducible "random"
- `SYNCLO_EXPLORE_MOCK_NO_DELAY=1` — skip inter-event waits
## Dataset
179 anonymized Langfuse traces from this project's own production
telemetry:
- 9 agents: claude 57 · opencode 41 · codex 38 · gemini 25 ·
cursor-agent 11 · qwen 2 · copilot 2 · deepseek 2 · antigravity 1
- outcomes: succeeded 144 · failed 35
- skills: default 71 · ad-creative 50 · algorithmic-art 30 ·
agent-browser 22 · video-hyperframes 2 · plus magazine-web-ppt /
brainstorming / data-report / penpot-flutter-design-source 1 each
- 124 multi-turn (sessions with ≥2 turns)
- 18 produce `<artifact>` output
- ~4.5 MB on disk total
Anonymization: `/Users/<name>/` → `${HOME}/`,
`C:\Users\<name>\` → `%USERPROFILE%\`, project UUIDs →
stable `proj-001`, `proj-002`, …. Tool input/output payloads
preserved verbatim (templated UI, no cell-level PII).
## Smoke test
`bash mocks/scripts/smoke-test.sh` — 6 checks across all 6 agents.
All pass on this branch (verified locally):
```
✓ opencode first event = step_start
✓ codex first event = thread.started
✓ claude first event = system
✓ deepseek emitted plain text (144 chars on first line)
✓ qwen emitted plain text (144 chars on first line)
✓ grok emitted plain text (144 chars on first line)
All mock CLIs working. ✅
```
## Adding more recordings
The exporter that produced this set lives in
[nexu-io/agent-pr-explore](https://github.com/nexu-io/agent-pr-explore)
(see `cli/src/local/orchestrator/langfuse-import.ts` + the `local
langfuse-import` CLI command). Operators with the Langfuse keys can pull
more by tag / outcome / artifact / multi-turn filter, then run
`local recordings anonymize --out-dir ~/Documents/open-design/mocks/recordings`.
`mocks/README.md` has the full instructions.
## Out of scope (follow-ups)
- **ACP agents** (`devin`, `hermes`, `kilo`, `kimi`, `kiro`, `vibe`) need
a JSON-RPC server on stdio rather than a one-shot stream — separate
`format-acp.mjs` module not yet written.
- **Per-agent json-event-stream variants** (`cursor-agent`, `gemini`,
`qoder`, `copilot`, `pi`) currently fall back to the `plain` renderer;
their parsers are in `apps/daemon/src/json-event-stream.ts` and follow
the same template as `format-codex.mjs`.
## AGENTS.md updates
- Added `mocks/` to the top-level content directories listing
- Added a Validation strategy bullet pointing here for agent-stream /
parser changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mocks): add opencode-cli/kiro-cli/vibe-acp bin aliases and unref ACP timeout
- Add mocks/bin/opencode-cli, kiro-cli, vibe-acp wrappers for the primary
RuntimeAgentDef bin names OD resolves before any fallback. Without these,
a PATH-overlaid OD daemon run bypasses the mock entirely (opencode-cli,
kiro-cli) or cannot find the mock at all (vibe-acp, which has no fallback).
- Include opencode-cli, kiro-cli, vibe-acp in the smoke-test ACP/JSON loop
so coverage is verified end-to-end.
- Call .unref() on the 30s safety timeout in format-acp.mjs so a completed
ACP session exits promptly instead of waiting the full 30 seconds.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* feat(mocks): add vela (AMR) — login / models / ACP with strict set_model gate
Extends mocks/ to cover OD's own AMR runtime. `vela` is the bin name
`apps/daemon/src/runtimes/defs/amr.ts` specifies (`bin: 'vela'`,
`streamFormat: 'acp-json-rpc'`). It's richer than the generic ACP
agents — covers full login + models + chat-session lifecycle.
### What vela does (mirrored from apps/daemon/tests/fixtures/fake-vela.mjs)
1. `vela login` — writes ~/.amr/config.json with a fake profile (controlKey,
runtimeKey, user{email,name,plan}, profile-specific apiUrl/linkUrl).
The on-disk projection is what OD's daemon login route + AmrLoginPill
poller read; production goes through device-auth, the mock skips
straight to the file write.
2. `vela models` — prints the production-shaped public model catalog as
newline-separated `public_model_* vela` lines. Override via
FAKE_VELA_MODELS env.
3. `vela agent run --runtime opencode` — ACP JSON-RPC server with three
vela-specific protocol extensions:
a. `initialize` response carries `agentCapabilities`
(`promptCapabilities.embeddedContext`) + `models`
(`currentModelId` + `availableModels`).
b. `session/new` response carries the same `models` block.
c. **Strict set_model gate**: `session/prompt` is rejected with
JSON-RPC -32602 ("session/set_model must be called before
session/prompt") UNLESS `session/set_model` (or
`session/set_config_option`) has been called for the current
sessionId. Mirrors real vela 0.0.1 contract; catches regressions
in `attachAcpSession` that silently skip set_model.
### Error injection envs (in sync with fake-vela.mjs)
FAKE_VELA_SESSION_ID - sessionId returned by session/new
FAKE_VELA_TEXT - override assistant text
FAKE_VELA_THOUGHT - optional thought_chunk before text
FAKE_VELA_SESSION_NEW_ERROR - fail session/new
FAKE_VELA_SET_MODEL_ERROR - fail session/set_model
FAKE_VELA_PROMPT_ERROR - fail session/prompt
FAKE_VELA_REQUIRE_SET_MODEL='0' - disable the strict gate (legacy)
FAKE_VELA_LOGIN_USER_EMAIL - email written into config profile
FAKE_VELA_LOGIN_USER_PLAN - plan written into config profile
FAKE_VELA_LOGIN_DELAY_MS - sleep before write (test in-flight)
FAKE_VELA_LOGIN_FAIL - print + exit 1
FAKE_VELA_MODELS - override models stdout
VELA_PROFILE - profile slot (prod | test | local)
### Components
`mocks/lib/format-vela.mjs` (~205 LOC)
- Full ACP server with vela protocol extensions
- Strict set_model gate
- Error injection plumbing
`mocks/lib/vela-subcommands.mjs` (~90 LOC)
- runVelaLogin() — writes ~/.amr/config.json
- runVelaModels() — prints catalog
`mocks/bin/vela` — dispatcher wrapper. Forwards `vela <subcmd>` to
mock-agent.mjs which routes to login/models or falls through to ACP.
`mocks/mock-agent.mjs` — parseArgs now collects positionals so the vela
dispatcher can read subcommand from there; switch case added for vela.
`mocks/scripts/smoke-test.sh` — +4 assertions:
vela models prints ≥10 catalog lines
vela login writes ~/.amr/config.json with the requested email
vela agent run ACP roundtrip (initialize+models+set_model+stream+result)
vela strict set_model gate rejects prompt without prior set_model
### Verified locally
✓ vela models printed 15 catalog lines
✓ vela login wrote ~/.amr/config.json with profile.prod.user.email
✓ vela agent run ACP roundtrip (initialize+models, set_model accepted, prompt streamed)
✓ vela strict set_model gate rejects session/prompt without prior set_model
All 21 smoke checks pass (up from 17 with previous P3 ACP commit).
### AGENTS.md + README updates
AGENTS.md — mention `vela (AMR — vela CLI)` alongside ACP agents in
the directory listing entry.
mocks/README.md — protocol table row + dedicated vela section with
subcommand contract, strict gate explanation, env-injection cheat
sheet. Mock-tree listing updated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mocks): honor REPORT_FILE env when --report-file flag not given
Harnesses that spawn the mock without translating their report-path
contract to the mock's CLI flag (notably nexu-io/agent-pr-explore's
orchestrator, which passes REPORT_FILE as env per the existing
opencode/claude/codex agent launchers) wouldn't get a report file
written, so the harness's "agent exit 0 but produced no report"
check would always fire and mark mock runs as failure even though the
stdout stream was complete.
Fix: in mock-agent.mjs parseArgs, fall through to process.env.REPORT_FILE
when --report-file wasn't provided on argv. Each format renderer already
accepts opts.reportFile and writes the recording's final assistant text
to it (`format-*.mjs` already had this — only the wiring was missing).
Verified: synclo-explore run with `mock=true, mock_trace=04097377`
against the opencode wrapper now produces a plan.md with the recording's
17-tool claude editing session report. ~1.5s per run vs ~70s real opencode.
* mocks: move recordings to Cloudflare R2; PR→main→Action upload path
The 179-recording corpus (~4.5 MB raw, ~280 KB after compression) has
been moved off git into Cloudflare R2 at the bucket open-design-mocks
under recordings/v1/. The repo now ships:
- mocks/manifest.json — the canonical catalog (renamed from
recordings/index.json) with sha256 + storage hints; consumers
fetch this to discover what exists, then pull individual jsonl
files on demand
- mocks/scripts/fetch-recordings.sh — parallel, sha256-verified,
idempotent puller for the public r2.dev URL
- mocks/scripts/add-recording.sh — local maintainer helper that
validates a new .jsonl and copies it into recordings-staging/
(no R2 calls; no credentials needed)
- mocks/scripts/upload-to-r2.mjs — called only by the CI workflow
- mocks/scripts/lib/manifest-utils.mjs — shared sha256/meta/
rebuild-histograms logic, used by both add-recording (preview)
and upload-to-r2 (actual write) so the entry shape never drifts
- .github/workflows/sync-mocks-to-r2.yml — fires on push to main
when mocks/recordings-staging/ changes; uploads to R2, updates
manifest, commits cleanup back; serialized via concurrency group
Trust model: R2 write credentials (CLOUDFLARE_API_TOKEN,
CLOUDFLARE_ACCOUNT_ID) are repo secrets; nobody can push from a
laptop. Read stays public via the r2.dev URL.
Why not pnpm install integration: contributors who do not touch
agent code do not pay the fetch cost. Fetch happens on first
smoke-test run (auto-fallback) or when a mock spawn needs data.
Repo size: -4.55 MB net (delete 179 jsonl, +280 KB manifest +
scripts). Smoke test (21 checks) still green against the fetched
corpus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: scope R2 write token to a dedicated secret name
Use CLOUDFLARE_R2_MOCKS_TOKEN (instead of reusing the shared
CLOUDFLARE_API_TOKEN that landing-page-*.yml uses for Pages deploys)
so the R2 write capability can be scoped to just the
open-design-mocks bucket without bleeding extra capability into the
Pages workflows.
Also hardcode the powerformer CF account_id directly in the workflow
(account IDs are not secret and the shared CLOUDFLARE_ACCOUNT_ID
secret may point at a different account).
Workflow now fails fast with an actionable error message + dashboard
link if the secret is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: switch R2 sync to S3-compat API (wrangler getMemberships gate)
wrangler 4.x calls /memberships before any r2 action, requiring
user:read scope. R2 "Object Read & Write" tokens deliberately lack
that scope (defense in depth — a leaked token should not enumerate
account-level resources). The workflow now uses the aws CLI talking
straight to the R2 S3-compatible endpoint with SigV4, no membership
lookup.
Secret rotation: CLOUDFLARE_R2_MOCKS_TOKEN (Bearer) is replaced by
CLOUDFLARE_R2_MOCKS_AK / CLOUDFLARE_R2_MOCKS_SK (matching the
existing CLOUDFLARE_R2_RELEASES_AK/SK naming convention). End-to-end
tested locally: PUT recording → manifest rebuild → manifest PUT →
staging cleanup all green.
aws CLI is pre-installed on ubuntu-latest, so no install step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: scrub synclo namespace; use OD_MOCKS_* env prefix throughout
These mocks were copy-pasted from synclo-explore, where they
originated, and inherited the SYNCLO_EXPLORE_MOCK_* env-var
convention. That brand-bleed is not appropriate in OD: rename the
public env surface to OD_MOCKS_* (matching OD-native prefixes like
OD_MOCKS_CACHE_DIR, OD_TRACE_R2_UPLOAD, OD_EXPECT_TIMEOUT_SECONDS).
Renames:
SYNCLO_EXPLORE_MOCK_TRACE → OD_MOCKS_TRACE
SYNCLO_EXPLORE_MOCK_BY_PROMPT_HASH → OD_MOCKS_BY_PROMPT_HASH
SYNCLO_EXPLORE_MOCK_POOL → OD_MOCKS_POOL
SYNCLO_EXPLORE_MOCK_SEED → OD_MOCKS_SEED
SYNCLO_EXPLORE_MOCK_NO_DELAY → OD_MOCKS_NO_DELAY
SYNCLO_EXPLORE_MOCK_RECORDINGS_DIR → OD_MOCKS_RECORDINGS_DIR
SYNCLO_EXPLORE_MOCK_SMOKE_TRACE → OD_MOCKS_SMOKE_TRACE
SYNCLO_OD_MOCKS_I_KNOW_WHAT_IM_DOING → OD_MOCKS_ALLOW_LOCAL_UPLOAD
Also drop the inline harvester usage from README. The harvester is an
external CLI in nexu-io/agent-pr-explore — its README is the right
place for langfuse-import flags, anonymization options, etc. OD only
documents its own staging→PR→Action workflow.
Smoke test (21 checks) still green; OD_MOCKS_TRACE end-to-end
verified to route correctly.
Consumers of the OLD env names (notably the orchestrator in
nexu-io/agent-pr-explore) need a matching rename. No back-compat
shim here — the explore side has zero external users today and a
one-line follow-up is cleaner than a permanent deprecation layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* AGENTS.md: align mock env names with mocks/ rename (SYNCLO_* → OD_MOCKS_*)
Missed in the prior commit (a30b868a) — only grepped mocks/ subdir.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: drop staging dir + GH Action; back to local-script upload
The staging-dir + Action design (added earlier in this PR) had a flaw
the user caught: new recordings briefly entered the repo on their way
through staging, leaving them in git history forever even after the
Action cleanup commit removed them from HEAD. That defeats the whole
point of moving recordings to R2.
Replace with the simpler local-maintainer flow:
bash mocks/scripts/upload-recording.sh /path/to/<trace>.jsonl
# → validates, wrangler r2 put, updates manifest.json, wrangler r2 put manifest
git add mocks/manifest.json && git commit && git push
# → only the ~200B manifest delta enters git
The wrangler-OAuth gate replaces the CI secret + Action duo. For a
solo / small maintainer team this collapses the trust chain down to
"do you have wrangler login to the powerformer account?" — no GH
secrets to rotate, no concurrency window to worry about, no
inevitable repo-history bloat.
Deletes:
- .github/workflows/sync-mocks-to-r2.yml
- mocks/scripts/upload-to-r2.mjs (CI-only)
- mocks/scripts/add-recording.sh (staging helper, now obsolete)
- mocks/recordings-staging/ (empty dir, never to be repopulated)
Adds:
- mocks/scripts/upload-recording.sh
Kept:
- mocks/scripts/fetch-recordings.sh
- mocks/scripts/lib/manifest-utils.mjs (still used by upload-recording.sh)
- mocks/manifest.json (committed; the only mocks artifact in git)
End-to-end tested locally: re-upload an existing recording is
idempotent, manifest math is stable, fetch + smoke test still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: address review — guard allowlist + safe ~/.amr + loud OD_MOCKS_TRACE typo
Three concrete issues raised across recent Siri-Ray (Looper) review
threads on #3241:
1. scripts/guard.ts only allowlisted mocks/lib/ + mocks/mock-agent.mjs,
leaving mocks/scripts/lib/manifest-utils.mjs outside the residual-
JS guard. Result: Preflight fail on every push. Extend the allowlist
to mocks/scripts/ — same precedent as the lib/ entry directly above.
2. mocks/scripts/smoke-test.sh moved the caller real ~/.amr to
~/.amr-smoke-backup, ran vela login (which writes a fake config),
then rm -rf the .amr and restored the backup. Two failure modes:
crash mid-run loses the user real config, and re-running before
restore overwrites the backup with the fake login. Fix: sandbox
vela login into a mktemp -d HOME via env (HOME=$amr_sandbox vela
login). Never touches the real ~/.amr at all. trap cleans up.
3. mocks/lib/recording-picker.mjs silently fell through to
prompt-hash → pool → random when OD_MOCKS_TRACE was set but did
not match any recording (typo, prefix too short, corpus not
fetched). Tests using a pinned trace would silently get a
different trace, hiding regressions. Fix: throw an explicit error
with the failing value + a pointer at fetch-recordings.sh.
Verified locally: pnpm guard prints "Residual JavaScript check
passed", smoke-test still 21/21, ~/.amr mtime unchanged after run,
typo on OD_MOCKS_TRACE now produces "mock-agent: OD_MOCKS_TRACE=...
set but no matching recording in <dir>" on stderr.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fetch-recordings: detect empty filter result before line-counting
printf '%s\n' on an empty string emits a single empty line, so the
previous TOTAL=$(printf ... | grep -c "") math returned 1 on an
empty $ENTRIES_TSV — a typo like `--agent no-such-agent` printed
"Fetching up to 1 recordings", downloaded zero, and exited 0
("ready"). Check `-z $ENTRIES_TSV` first.
Reproduced + fix verified per the reviewer thread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mocks: address mrcfps review — goldens + provenance + contract check
Three durability improvements suggested in the PR #3241 top-level
review:
## 1. Golden daemon-event snapshots (mocks/golden/*.events.json + apps/daemon/tests/mocks-golden.test.ts)
Smoke-test verified that mocks RUN; that catches crashes but not a
parser change that semantically reshapes the events the daemon emits.
Commit the daemon-event sequence for 3 representative traces:
- claude 314d6833 — median-complexity agent-browser session
- codex dcdff3b3 — 14-tool refactor
- opencode 9a9522ec — 7-tool data-report
apps/daemon/tests/mocks-golden.test.ts spawns the mock, feeds stdout
through the real createClaudeStreamHandler / createJsonEventStreamHandler,
normalizes per-spawn volatile fields (only sessionId today, only on
claude), and deep-equals against the committed snapshot. A parser
regression fails the test loudly.
After an intentional parser change, regenerate:
MOCKS_GOLDEN_UPDATE=1 pnpm --filter @open-design/daemon test mocks-golden
git diff mocks/golden/
# eyeball; commit if shapes match intent
## 2. Provenance fields on every manifest entry (mocks/scripts/lib/manifest-utils.mjs + mocks/manifest.json)
Augment inspectRecording() to write:
captured_at — ISO 8601 from existing meta.timestamp
cli_version — null until harvester writes it
protocol_version — null until harvester writes it
anonymization_version — null until harvester writes it
captured_at is now populated for all 179 existing entries from the
meta event the harvester already emits. The harvester in
nexu-io/agent-pr-explore is the next step for cli_version /
protocol_version / anonymization_version — once those are
populated, consumers can detect when a recording is older than ~1
minor version behind the live CLI and flag for re-harvest.
No matrix of (cli_version × agent) recordings — that explodes
maintenance. Just metadata per recording so trust decay is visible.
## 3. Real-CLI contract check (mocks/scripts/contract-check.sh + docs/MOCKS-CONTRACT-CHECK.md)
Mocks catch parser regressions against recordings; they do NOT
catch recordings drifting away from the live agent CLI as that CLI
evolves. The contract check spawns the real CLI alongside the mock
with a fixed deterministic prompt + diffs top-level event-type
distributions.
Deliberately human-driven, not cron-scheduled:
- costs real LLM tokens per invocation
- requires real CLI auth
- maintainer reads the output, not a regex
Suggested triggers per doc: real-CLI release notes mentioning
"output format" / "stream" / "JSON" / "events"; before a parser
refactor; ad-hoc when something looks off.
## Coverage note
README updated to position mocks as "deterministic protocol/parser
coverage" (not "e2e replacement") per mrcfps framing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mocks-golden test): drop import of non-exported ParserKind
Use plain string (the type alias is `string` anyway) — Preflight
typecheck on a31fa71a failed:
tests/mocks-golden.test.ts(29,8): error TS2459: Module
"../src/json-event-stream.js" declares "ParserKind" locally, but
it is not exported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* recording-picker: structured OD_MOCKS_POOL + hard-fail no-match
Siri-Ray review: \`OD_MOCKS_POOL=outcome:failed\` was documented as a
supported selection knob, but the matcher only checked tags and
\`meta.agent\` — so the negative-path pool found 0 candidates and
silently fell through to global random, validating against any
recording instead of a failed trace.
Fix:
- Parse \`<dim>:<value>\` shape and route each dim to the right meta
field: \`outcome\` → \`meta.outcome\`, \`agent\` → \`meta.agent\`,
\`skill\` → \`tags[]\`. Bare values still fall back to tag substring.
- If the env was set and matched nothing, throw with the failing
value and a jq one-liner for inspection. Same loud-fail policy as
OD_MOCKS_TRACE — silent fallback was the original bug.
Verified locally: outcome:failed, agent:codex, skill:agent-browser
all route correctly; outcome:nonsense throws the explicit error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* contract-check.sh: fix lost $PROMPT in mock invocation
Siri-Ray review on e576074a: the mock side wrapped its pipeline in
`bash -c "printf %s \"\$PROMPT\" | ..."` — but $PROMPT was a parent
shell variable, not exported, so the child bash expanded it to an
empty string. Result: the contract check sent the real prompt to the
real CLI and an empty string to the mock, defeating the
same-input invariant the whole script rests on. Also let the mock
randomly select a different trace whenever a maintainer happens to
have OD_MOCKS_BY_PROMPT_HASH=1 in their env.
Fix: drop the inner bash -c entirely; use a subshell that scopes the
PATH overlay and pipes printf into the PATH-resolved mock binary
directly. The subshell limits the PATH change without var-passing.
Verified locally: with prompt-A the mock picks trace 54ec02ee via
hash; prompt-B → 2667e851 via hash; empty prompt (old broken
behavior) → random — confirms the prompt is now actually reaching
the mock under PATH overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 KiB
Directory guide
This file is the single source of truth for agents entering this repository. Read this file first; after entering apps/, packages/, tools/, or e2e/, read that layer's AGENTS.md for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands.
Core documentation index
- Product and onboarding:
README.md,README.zh-CN.md,QUICKSTART.md. - Contribution and environment:
CONTRIBUTING.md,CONTRIBUTING.zh-CN.md. - Architecture and protocols:
docs/spec.md,docs/architecture.md,docs/skills-protocol.md,docs/agent-adapters.md,docs/modes.md. - Roadmap and references:
docs/roadmap.md,docs/references.md,docs/code-review-guidelines.md,specs/current/maintainability-roadmap.md. - Directory-level agent guidance:
apps/AGENTS.md,packages/AGENTS.md,tools/AGENTS.md,e2e/AGENTS.md. - Packaged auto-update architecture and high-confidence local harness: read
tools/pack/AGENTS.mdsection "Packaged auto-update architecture and harness" before touching packaged updater code, release-channel identity, installer behavior, or updater UI.
Workspace directories
- Workspace packages come from
pnpm-workspace.yaml:apps/*,packages/*,tools/*, ande2e. - Top-level content directories:
skills/(functional skills the agent invokes mid-task — utilities, briefs, packagers; seeskills/AGENTS.md),design-templates/(rendering catalogue: decks, prototypes, image/video/audio templates; seedesign-templates/AGENTS.mdandspecs/current/skills-and-design-templates.md),design-systems/(brandDESIGN.mdfiles),craft/(universal brand-agnostic craft rules a skill can opt into viaod.craft.requires),mocks/(replay-based mock CLIs foropencode/claude/codex/gemini/cursor-agent/deepseek/qwen/grok, the ACP familydevin/hermes/kilo/kimi/kiro/vibe, and the AMRvelaCLI (login + models + ACP), built from anonymized Langfuse traces — PATH-overlay drop-in for tests and self-validation; seemocks/README.md). apps/webis the Next.js 16 App Router + React 18 web runtime; do not restoreapps/nextjs.apps/daemonis the local privileged daemon andodbin. It owns/api/*, agent spawning, skills, design systems, artifacts, and static serving.apps/desktopis the Electron shell; it discovers the web URL through sidecar IPC.apps/packagedis the thin packaged Electron runtime entry; it starts packaged sidecars and owns theod://entry glue only.packages/contractsis the pure TypeScript web/daemon app contract layer.packages/sidecar-protoowns the Open Design sidecar business protocol;packages/sidecarowns the generic sidecar runtime;packages/platformowns generic OS process primitives.tools/devis the local development lifecycle control plane.tools/packis the local packaged build/start/stop/logs control plane, packaged updater harness, installer identity/registry validation surface, and mac beta release artifact preparation surface.tools/serveis the local fixture-service control plane; first service istools-serve start updaterfor deterministic updater metadata and artifacts.e2eowns user-level end-to-end smoke tests and Playwright UI automation; reade2e/AGENTS.mdbefore editing its tests or commands.
Inactive or placeholder directories
apps/nextjsandpackages/sharedhave been removed; do not recreate or reference them..od/,.tmp/, Playwright reports, and agent scratch directories are local runtime data and must stay out of git.
Development workflow
Environment baseline
- Runtime target is Node
~24andpnpm@10.33.2; use Corepack so the pnpm version pinned inpackage.jsonis selected. - New project-owned entrypoints, modules, scripts, tests, reporters, and configs should default to TypeScript.
- Residual JavaScript is limited to generated output, vendored dependencies, explicitly documented compatibility build artifacts, and the allowlist in
scripts/guard.ts.
Windows native
- macOS, Linux, and WSL2 are the primary supported paths. Windows native is best-effort — file an issue if it doesn't work.
- Historical Windows-specific friction is documented in closed issues #10, #96, #100, #203, and #315; check the issue tracker for the current state before filing new reports.
- Install Node 24. Either
winget install OpenJS.NodeJS.LTS(currently Node 24.x) or download from https://nodejs.org. After install, verify withnode --version— the WinGet LTS pointer rolls to the next major in October 2026, so re-verify if you re-run the install command later. Do not use Node 22 — see FAQ. corepack enablefails with EPERM on Windows (cannot write shims toProgram Files). Usenpm install -g pnpm@10.33.2instead.better-sqlite3has no prebuilt binary for win32/Node 24;pnpm installwill compile it from source via node-gyp (~2 min). Requires Visual Studio Build Tools 2022 or newer. This is expected — not a sign of version incompatibility.- For
tools-devstart/stop/status usage, see "Local lifecycle" below.
Local lifecycle
- Use
pnpm tools-devas the only local development lifecycle entry point. - Do not add or restore root lifecycle aliases:
pnpm dev,pnpm dev:all,pnpm daemon,pnpm preview, orpnpm start. - Ports are governed by
tools-devflags:--daemon-portand--web-port. tools-devexportsOD_PORTfor the web proxy target andOD_WEB_PORTfor the web listener; do not useNEXT_PORT.
Root command boundary
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints:
pnpm guard,pnpm typecheck,pnpm tools-dev,pnpm tools-pack, andpnpm tools-serve. - Do not add root aggregate
pnpm buildorpnpm testaliases. Build/test commands must stay package-scoped (pnpm --filter <package> ...) or tool-scoped (pnpm tools-pack ...). - Do not add root e2e aliases; e2e package commands and ownership rules live in
e2e/AGENTS.md.
Release channel model
betais the daily R&D/development validation channel. It is optimized for fast development feedback and is not part of the stable promotion gate.nightlyis the internal validation channel for stable delivery. Stable releases remain gated by validated nightly artifacts.previewis an independent early-access channel with stable-like release rigor. It should use preview versions such asX.Y.Z-preview.N, publish to thepreviewR2 channel, publish updater feeds underpreview/latest, and follow stable's platform policy including the existing optional Linux enablement.stableis the formal delivery channel. Do not make stable promotion depend on preview; stable continues to depend on nightly only.- Public packaged app identity must stay channel-distinct: stable uses
Open Design, beta usesOpen Design Beta, and preview usesOpen Design Preview. Do not ship beta or preview mac DMGs whose drag-install app bundle isOpen Design.app. - Windows beta updater validation must use the real beta namespace
release-beta-win; otherwise a local beta-like namespace can create a separate uninstall registry key while looking like the sameOpen Design Betaapp. Seetools/pack/AGENTS.mdfor the architecture map and high-confidence acceptance harness.
Boundary constraints
- Tests under
apps/,packages/, andtools/live in a package/app/tool-leveltests/directory sibling tosrc/; keepsrc/source-only and do not add new*.test.tsor*.test.tsxfiles undersrc/. Playwright UI automation belongs toe2e/ui/, not app packages. - App packages must not import another app's private
src/ortests/implementation as a shared helper. In particular,apps/web/**must not importapps/daemon/src/**; web/daemon integration belongs behind HTTP APIs,packages/contracts, and app-local provider boundaries. - Cross-app, cross-runtime, or repository-resource consistency checks belong in
e2e/tests/when they need to observe more than one app/package boundary; promote reusable logic to a pure package instead of borrowing another app's private source. - Keep shared API DTOs, SSE event unions, error shapes, task shapes, and example payloads in
packages/contracts; update contracts before wiring divergent web/daemon request or response shapes. - Keep
packages/contractspure TypeScript and free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, and sidecar control-plane dependencies. - Keep project-owned entrypoints, modules, scripts, tests, reporters, and configs TypeScript-first; generated
dist/*.jsis runtime output, and source edits belong in.tsfiles. - New
.js,.mjs, or.cjsfiles need an explicit generated/vendor/compatibility reason and must passpnpm guard. - App business logic must not know about sidecar/control-plane concepts. Keep sidecar awareness in
apps/<app>/sidecaror the desktop sidecar entry wrapper. - Shared web/daemon app contracts belong in
packages/contracts; that package must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol. - Sidecar process stamps must have exactly five fields:
app,mode,namespace,ipc, andsource. - Orchestration layers (
tools-dev,tools-pack, packaged launchers) must call package primitives; do not hand-build--od-stamp-*args or process-scan regexes. - Packaged runtime paths must be namespace-scoped and independent from daemon/web ports; ports are transient transport details only.
- Default runtime files live under
<project-root>/.tmp/<source>/<namespace>/...; POSIX IPC sockets are fixed at/tmp/open-design/ipc/<namespace>/<app>.sock.
Capability exposure (UI/CLI dual-track)
Every user-facing capability must be reachable through both the web UI and the od CLI (apps/daemon/src/cli.ts). Shipping a feature with only one of the two surfaces is a regression.
- The CLI is the embeddability contract. External agents (hermes-agent, openclaw, custom Slack/Discord bots, packaged runtimes invoked from another shell) drive Open Design through
odsubcommands — they do not render the web UI. If a capability is UI-only, it cannot be composed into those external agents. - Both surfaces must call the same
/api/*endpoints; do not let the CLI talk to one shape and the UI to another. The daemon HTTP layer is the single source of truth, withpackages/contractscarrying the shared DTOs. - The CLI form must support
--jsonfor machine-readable output and accept long-form prompts via--prompt-file <path|->, so jobs that pipe throughxargs,jq, and<heredocstay clean. - Adding a new capability is a three-step closure: HTTP endpoint in
apps/daemon/src/*-routes.ts(with a contract type inpackages/contracts/src/api/), UI surface inapps/web/src/, andod <capability>subcommand inapps/daemon/src/cli.tsregistered throughSUBCOMMAND_MAP. Land all three in the same PR; do not stage them across PRs. - The PR template's Surface area checklist must reflect both surfaces. If you ticked UI, tick CLI too — and vice-versa — or explain in the PR body why the missing surface is genuinely not applicable (e.g. an internal-only daemon health probe). "I'll do the CLI later" is not a valid reason.
- Existing reference points:
od automation …mirrors the Automations tab against/api/routines;od plugin …,od ui …,od project …,od media …,od mcp …,od research …follow the same shape. Copy that pattern for new capabilities.
Git commit policy
- Git commits must not include
Co-authored-bytrailers or any other co-author metadata.
Pull request expectations
- Opening a PR uses
.github/pull_request_template.md; fill every section, not just the title. - "Why" must answer both the author's use case (what made you write this PR) and the pain being addressed (user problem, technical debt, prod issue, or unblocker), not just a one-line restatement of the title.
- "What users will see" describes the change from a user's perspective — what they click, what new thing appears, what default behavior changed — not from a code perspective.
- The Surface area checklist must reflect actual surfaces touched; check every box that applies, including extension points (
skills/,design-systems/,design-templates/,craft/), CLI flags, env vars, i18n keys, and new rootpackage.jsondependencies. - If any UI surface is checked, attach screenshots showing the entry point — where users discover the change — not just the feature in isolation; before/after is best for behavior changes.
- For bug-fix PRs, link the red-spec test that reproduces the bug and confirm it went red on
mainand green on the branch, per theBug follow-up workflowsection above. CONTRIBUTING.mdcovers PR scope, title format, dependency policy, and the issue-first rule for non-trivial features;docs/code-review-guidelines.mdis the reviewer-facing complement.
Code review guide
- Use
docs/code-review-guidelines.mdas the repository-wide review standard. That document is the operational guide; thisAGENTS.mdis the source of truth when the two disagree. - Walk reviews top-down through
docs/code-review-guidelines.md: Product relevance test → forbidden surfaces → ownership/scope → matching lane → checklist → comments → approval bar. - Pick the matching review lane: default code/tests, contract and protocol changes, design-system additions, skill additions, or craft additions.
- Before reviewing changes under
apps/,packages/,tools/, ore2e/, read that directory'sAGENTS.mdand apply its local boundaries. - Blocking review feedback should focus on correctness, security/secrets, data integrity, repository boundary violations, contract/migration breakage, missing required validation, or high-risk maintainability issues.
- Only maintainers may close a PR instead of requesting changes, and only when the change is not salvageable on the existing branch (wrong target product, foreign test harness, DOM/API assumptions absent from this repo, or scripts that conflict with lifecycle rules).
PR-duty tooling
This repository no longer ships a maintainer PR-duty control plane. The former
pnpm tools-pr workflow has moved to the standalone PerishCode/duty project
so personal review-lane automation does not become product workspace
maintenance surface. Do not recreate tools/pr, @open-design/tools-pr, or a
root pnpm tools-pr script without a new explicit maintainer decision.
Agent runtime conventions
RuntimeAgentDef.promptInputFormatselects how the daemon writes the prompt to a child's stdin. The default'text'writes the composed prompt and ends stdin immediately.'stream-json'wraps the prompt as one JSONLusermessage and KEEPS stdin open so the daemon can stream further user messages back in mid-turn. Claude (apps/daemon/src/runtimes/defs/claude.ts) ships'stream-json'together with--input-format stream-jsonso the host can answer interactive tools likeAskUserQuestionwith a realtool_resultblock. Every other agent stays on'text'.apps/daemon/src/server.tstracksrun.pendingHostAnswers(a Set oftool_use_idstrings) andrun.stdinOpenon the run object. Theclaude-stream-jsonevent handler adds AskUserQuestion ids to the set and closes stdin only when both the set is empty AND aturn_end(orusage) event arrives with a nontool_usestop_reason. Thetool_usestop reason means the model paused mid tool (waiting on claude-code's internal runner or on a host answer); closing stdin there would truncate the follow up response.claude-stream.tsemits theturn_endevent AFTER iterating the assistant message's content blocks, not before. When--include-partial-messagesis unsupported, tool_use events surface only from the assistant wrapper, so emittingturn_endfirst would let the daemon close stdin before the host had registered any pending answers.POST /api/runs/:id/tool-resultis the daemon endpoint for feeding atool_resultblock back into a still running stream-json child. Body shape:{ toolUseId: string, content: string, isError?: boolean }. Web callers usesubmitChatRunToolResultfromapps/web/src/providers/daemon.ts. The daemon writes a JSONLusermessage containing onetool_resultcontent block, removes the id frompendingHostAnswers, and lets the nextturn_enddecide when to close stdin.- AskUserQuestion specifically: Claude's system prompt section in
apps/daemon/src/prompts/system.ts(Claude only block at the bottom ofcomposeSystemPrompt) tells the model to use the tool for 2 to 4 finite choices, and to stop generating tokens after the tool call instead of also writing a markdown duplicate.AssistantMessage.suppressAskUserQuestionFallbackTextis the belt and suspenders that hides any trailing markdown text in the same turn.
Chat UI conventions
apps/web/src/components/file-viewer-render-mode.tsdecides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier toUrlLoadDecisionwhenever a feature needs a srcDoc-only bridge; pass it fromFileViewer.tsxbased on a source-content heuristic where appropriate (e.g.hasTweaksTemplate). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash;iframeRef.currentstays aligned with the active iframe viauseEffect. Receive filters useisOurIframe(ev.source)to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g.od:tweaks-available) re-checkev.source === iframeRef.current?.contentWindow.- TodoWrite UI pins one canonical task list above the chat composer via
PinnedTodoSlotinChatPane.tsx. The slot reads the latest TodoWrite snapshot across the conversation throughlatestTodoWriteInputFromMessages(apps/web/src/runtime/todos.ts).AssistantMessage.stripTodoToolGroupsremoves any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes bothcompletedandin_progressitems (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card.PinnedTodoSlotsits OUTSIDE the.chat-logscroll container, so auto-scroll requires explicit coverage:ChatPane'sResizeObserveraccepts acontainerReffromPinnedTodoSlotand observes that element directly, and a pane-levelMutationObserver(childList: trueon the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive. AskUserQuestionCard(inToolCard.tsx) prefers the liveonAnswerToolUse(toolUseId, content)route (POSTs to/api/runs/:id/tool-result) and falls back to the legacyonSubmitForm(text)path when the run has already terminated. Selected chips persist across reloads by parsing the storedtool_result.contentback into the selections shape.- Tool group rendering uses
dedupeSnapshotToolRetriesto collapse identicalAskUserQuestionretries (one card per unique input, keeping the latest tool_use_id) andTodoWritesnapshots (only the most recent call, since each call is a state replace).
Web CSS ownership
apps/web/src/index.cssis an import-only cascade entrypoint. Do not add selectors or declarations there; add imports only when a truly global stylesheet is needed, and keep import order intentional.- Shared global styles belong in
apps/web/src/styles/: design tokens, base/reset rules, primitives, app-shell layout, and legacy cross-component selectors that cannot safely be scoped yet. Keep domain-level global files grouped by owner (for examplestyles/viewer/andstyles/workspace/) instead of adding more large files directly understyles/. - New component-owned UI styles should default to CSS Modules next to the component (
Component.module.css) instead of expanding global stylesheets. This is preferred for isolated components, panels, menus, drawers, toolbars, cards, and form sections. - When touching an existing component with nearby global styles, prefer migrating that component's local selectors to a CSS Module as part of the change if it is small and testable. Do not mix a large mechanical move with behavior/styling changes in the same patch.
- Keep global class names only for deliberate shared contracts: reusable primitives, theme hooks, third-party/content styling, cross-component layout, or selectors that rely on global cascade/specificity. Document any new global selector group with its owning feature.
- CSS refactors must preserve cascade semantics. For mechanical splits, verify expanded import content/order matches the previous stylesheet; for CSS Module migrations, validate the affected UI path with
pnpm --filter @open-design/web typecheckand a focused build/test or visual check when practical.
i18n keys
apps/web/src/i18n/types.tsis the typedDict; every key must be defined in all 18 locale files underapps/web/src/i18n/locales/*.ts(ar,de,en,es-ES,fa,fr,hu,id,ja,ko,pl,pt-BR,ru,th,tr,uk,zh-CN,zh-TW). Add the key totypes.tsfirst; missing translations produce a typecheck error.
UI animation philosophy
- Default ease-out for UI transitions:
cubic-bezier(0.23, 1, 0.32, 1). Built-ineaseis too weak;ease-inis forbidden for UI elements because it feels sluggish. - Asymmetric durations: enter around 200ms, exit around 140ms. Exit reads as decisive because the user has already chosen to dismiss.
- Accordion expand and collapse uses
grid-template-rows: 0fr -> 1fr(modern auto height pattern). Pair with opacity fade and the easing above. The shared.accordion-collapsible+.accordion-collapsible-innerclass pair (defined inapps/web/src/index.css) is the canonical implementation; reuse it for new disclosure UI. - Never animate from
transform: scale(0). Start fromscale(0.9)or higher withopacity: 0. - For elements that show conditionally, keep them mounted and toggle a CSS class (e.g.
.chat-jump-btn-active). React unmounts skip the exit transition entirely.
Validation strategy
- After package, workspace, or command-entry changes, run
pnpm installso workspace links and generated dist entries stay fresh. - For agent-stream / parser changes (
apps/daemon/src/claude-stream.ts,json-event-stream.ts,qoder-stream.ts, etc.), replay a recorded session through the mock CLIs inmocks/to verify event shapes round-trip without burning provider budget. PATH-overlay activation:export PATH="$PWD/mocks/bin:$PATH" OD_MOCKS_TRACE=<8-char-id> OD_MOCKS_NO_DELAY=1. Seemocks/README.mdfor the trace catalog and selection knobs. - Treat every
pnpm-lock.yamlchange as requiring a Nix pnpm deps hash refresh check.nix/pnpm-deps.nixis a generated lock artifact; usepnpm nix:update-hashonly when intentionally maintaining Nix packaging, then re-runnix flake check --print-build-logs --keep-going. Contributors without Nix can rely on the PRValidate workspacegate, which now uploads or auto-applies the generated hash-only fix when possible. - Before marking regular work ready, run at least
pnpm guardandpnpm typecheck, plus the package-scoped tests/builds that match the files changed. Do not use or add rootpnpm test/pnpm buildaliases. - For local web runtime loops, prefer
pnpm tools-dev run web --daemon-port <port> --web-port <port>. - On a GUI-capable machine, validate desktop by running
pnpm tools-dev, thenpnpm tools-dev inspect desktop status. - Stamp/namespace changes must validate two concurrent namespaces and run desktop
inspect evalplusinspect screenshotfor each namespace. - Path/log changes must run
pnpm tools-dev logs --namespace <name> --jsonand confirm log paths are under.tmp/tools-dev/<namespace>/....
Bug follow-up workflow
The following is a working playbook for routine bug follow-ups, distilled from recent practice. Treat it as a default action shape, not a contract — production reality always has edges these bullets can't anticipate, so use judgment when the situation doesn't fit cleanly.
- Lead with a red spec. Default to encoding the bug as a falsifiable test that goes red before any source change, so the fix is anchored in observable behavior rather than source-code intuition. If a red spec can't be written cheaply, that's usually a signal to clarify scope rather than push forward on a guess.
- Try the cheapest layer first. Reach for the lightest test layer that can still see the symptom (e2e Vitest at the daemon HTTP boundary → app-local Vitest → Playwright UI → platform-native harnesses), and drop down only when the cheaper layer can't.
- Hold the spec's scope. Defects discovered outside the bug's described boundary belong in a follow-up — their own red spec, their own PR — not in this fix. List them in the PR body's "Adjacent issues" section with the rationale and move on.
- Let the fix read as an invariant. Prefer a named helper whose docblock describes what must hold over a bolt-on
ifguard with apologetic history-comments. The call site should read as intent. - Diff against the baseline. When neighboring suites have pre-existing failures, stash or check out upstream before claiming "no new failures."
- Link the issue from the PR body. Use
Fixes #N/Closes #N/Resolves #Nso the issue auto-closes on merge and the release-time reverse lookup (gh issue view N --json closedByPullRequestsReferences→git tag --contains <merge sha>) actually has a chain to follow. The repo's PR template prompts for this; deleting the prompt is fine when the PR genuinely closes nothing. - Stage human verification for visible bugs. When the symptom needs an eye to confirm — UI, platform-native behavior, animations, race conditions a unit test can't see — green specs alone aren't acceptance. Stand up a buggy-vs-fix comparison the reviewer can drive themselves (typical shape: two namespaced runtimes, one on
main, one on the fix branch), and seed any required data only through production HTTP APIs; source-level test backdoors invalidate the verification because they prove a fake flow rather than the real one.
For a worked example of one full loop (red e2e spec → fix → green), see e2e/tests/dialog/stop-reconciles-message.test.ts (issue #135).
Common commands
pnpm install
pnpm nix:update-hash
pnpm tools-dev
pnpm tools-serve start updater
pnpm tools-dev start web
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
pnpm tools-dev status --json
pnpm tools-dev logs --json
pnpm tools-dev inspect desktop status --json
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
pnpm tools-dev stop
pnpm tools-dev check
pnpm guard
pnpm typecheck
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test
pnpm --filter @open-design/web build
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/tools-dev build
pnpm --filter @open-design/tools-pack build
pnpm --filter @open-design/tools-serve build
pnpm tools-pack mac build --to all
pnpm tools-pack mac install
pnpm tools-pack mac cleanup
pnpm tools-pack win build --to nsis
pnpm tools-pack win install
pnpm tools-pack win cleanup
pnpm tools-pack linux build --to appimage
pnpm tools-pack linux install
pnpm tools-pack linux build --containerized
FAQ
Why is there no root pnpm dev / pnpm start?
To avoid starting daemon, web, and desktop through inconsistent env, port, namespace, or log paths. All local lifecycle flows must go through pnpm tools-dev.
Why should apps/nextjs not be restored?
The current web runtime is apps/web. The historical apps/nextjs layout has been removed from the active repo shape; restoring it would reintroduce duplicate app boundaries and stale scripts.
How does desktop discover the web URL?
Desktop queries runtime status through sidecar IPC. The web URL comes from tools-dev launch status, not from desktop guessing ports or reading web internals.
How are sidecar-proto, sidecar, and platform split?
@open-design/sidecar-proto owns Open Design app/mode/source constants, namespace validation, stamp fields/flags, IPC message schema, status shapes, and error semantics. @open-design/sidecar provides only generic bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime files. @open-design/platform provides only generic OS process stamp serialization, command parsing, and process matching/search primitives, consuming the proto descriptor.
Where is data written?
The daemon writes .od/ by default: SQLite at .od/app.sqlite, agent CWDs under .od/projects/<id>/, saved renders under .od/artifacts/, and credentials at .od/media-config.json. Two env vars override the storage root, in order:
OD_DATA_DIR=<dir>— relocates all daemon runtime data to<dir>(used by Playwright for test isolation, and by the packaged daemon and the Home Manager / NixOS modules to point the daemon at a writable directory when the install root is read-only). The path is resolved with~/expansion and relative paths anchored to<projectRoot>.OD_MEDIA_CONFIG_DIR=<dir>— narrower override that relocates onlymedia-config.json. Same resolution semantics. Most installs do not need this; it exists for setups that want to keep API credentials in a different location from the rest of the runtime data.
Default precedence is OD_MEDIA_CONFIG_DIR > OD_DATA_DIR > <projectRoot>/.od.
When is pnpm install required?
Run pnpm install after changing package manifests, workspace layout, command entrypoints, bin/link-related content, or after adding/removing workspace packages.
Can I use Node 22 instead of Node 24?
No. package.json#engines specifies node: "~24", which is the only supported runtime. The current lockfile pins better-sqlite3@11.10.0; on Windows it has no prebuilt binary for Node 24 and is built from source via node-gyp (see the Windows native section). Older Node versions are not tested and may hit lockfile or dependency incompatibilities.