Commit Graph
594 Commits
Author SHA1 Message Date
lefarcenandGitHub 204ee7a06d fix(daemon): explain an ACP handshake rejection instead of retrying it (#7303)
* fix(daemon): explain an ACP handshake rejection instead of retrying it

An agent CLI that answers `initialize` and then refuses `session/new` /
`session/load` surfaced as a bare `json-rpc id 2: Internal error`, and the
retry policy treated it as transient — so the run failed, retried, and failed
identically while the user read a JSON-RPC frame.

Classify a handshake-numbered protocol error as `session_init` /
`install_cli`, keep every later protocol error on `child_close`, and lead the
user-facing message with the CLI and its detected version. The raw line stays
appended because `run.error` is also the classifier's input.

* fix(daemon): route the ACP handshake guidance onto the path users hit

The guidance copy was only reachable through `rewriteKnownAgentStreamError`,
which the ACP failure path never calls. An ACP agent's error goes
`attachAcpSession` -> `fail()` -> the bridge's `send('error', payload)`, and
that callback forwarded the payload verbatim; the close handler then returned
on `acpFatalErrorObservedBeforeCancellation && hasFatalError()` before the
stderr-tail rewrite further down. So a Kimi user still read
`json-rpc id 2: Internal error`.

Rewrite the payload at the bridge instead. That one object is the whole
user-visible surface of an ACP failure: `send` streams it to SSE clients and
`design.runs.emit` reads `run.error` out of it, so correcting it there fixes
both surfaces at once. `withAcpHandshakeFailureGuidance` rewrites only the
message fields, and only for a handshake-numbered JSON-RPC error, so
`AMR_MODEL_UNAVAILABLE`, promoted opencode errors, and post-session protocol
errors keep the shape their own handling depends on. The raw line stays
embedded because `run.error` is also the classifier's input.

Cover it through the full server run cycle with a fake ACP CLI that answers
`initialize` and rejects `session/new`, asserting on what a client and the
telemetry pipeline actually observe. Against the pre-fix tree the run error is
the bare `json-rpc id 2: Internal error`; against the pre-PR tree the daemon
also opened `session/new` twice.

* fix(daemon): read the handshake failure's cause, not just its id

Running a real Kimi CLI while signed out produces `json-rpc id 2:
Authentication required`. The handshake reading keyed on the JSON-RPC id
alone, so that user was told their CLI version was incompatible and they
should update or reinstall it — a prescription for a problem they did not
have, in place of the one sentence that pointed at the fix.

The id answers *when* a failure happened; it can never answer *why*. Split
the two questions: `isAcpHandshakeRpcErrorText` stays structural, and
`isAcpCliSessionRefusalText` is what the copy and the `install_cli` verdict
now hang off — handshake numbering AND no cause with a remedy of its own.
The "has its own remedy" half reuses `classifyAgentServiceFailure` rather
than growing a second signature list: it is agent-agnostic, already covers
auth / quota / upstream, and carries its own suite. It is deliberately
unrelated to, and narrower than, `isCliNotInstalledText` — that predicate
answers "was the binary even there", which a CLI that already answered
`initialize` has plainly settled.

A handshake failure that names its cause now keeps its own text and falls
through to the branch that owns that cause, so telemetry and copy agree: an
unauthenticated handshake is `auth` / `login`, a throttled one is
`rate_limit`. A bare refusal (`Internal error`, `Method not found`) is
unchanged and still gets the CLI guidance and the retry suppression.

No pure-function test could have caught this: the helpers were consistent
with themselves. The wiring test drives a fake ACP CLI that rejects
`session/new` with `Authentication required` and asserts on the text a
signed-out user actually reads.

* test(daemon): pin the category each named handshake cause is filed under

Verifies the routing claimed in the PR body rather than asserting it. Caught
one: `insufficient balance` has its own `insufficient_balance` category, not
`rate_limit`.

* fix(chat): name the ACP session refusal instead of writing its sentence

The guidance this PR added reached the user as a paragraph the daemon
composed: "The Kimi CLI (0.38.0) accepted the connection but refused to start
a session. … Details: json-rpc id 2: Internal error". That is the wrong layer,
and it showed:

  - The daemon has no locale. The string went straight into `run.error` and
    the chat rendered it verbatim, so a Chinese UI put a Chinese title over an
    English body. No amount of rewording fixes that; only moving the sentence
    to the client does.
  - The paragraph restated the raw agent line, which the card's details block
    already prints — the same text twice in one card.

So the daemon now NAMES the failure and stops there: `AGENT_CLI_SESSION_REFUSED`
plus `details: { kind: 'agent_cli', action: 'update_cli', agent, agentCliVersion }`,
with `retryable: false` because a build that refuses `session/new` refuses the
identical request identically. `withAcpHandshakeFailureGuidance` stays the single
invariant point (the ACP bridge payload feeds SSE and `run.error` at once) and now
stamps only the structured half; the stderr-tail fallbacks wrap their payloads in
it too, so no path is left composing prose.

`run.error` keeps the agent's `json-rpc id N: …` line byte for byte. It is the
classifier's input — rewriting it degrades this failure class to `unknown` in
telemetry — and it is what the details block shows, so leaving it alone is also
what removes the duplicate.

The web maps the code to localized copy in `resolveRunFailureUi`, resolved before
every other branch because the copy is not a fixed sentence: it names the CLI
build the daemon detected, and falls back to a version-less wording when there
was none (the same rule the rolling model-window copy follows). The version rides
to the client as data, on the status event next to `code` / `failureDetail`.
Three keys in all 19 locales.

Red first, on the path users hit: the wiring suite (real `startServer` + a fake
ACP CLI) went red on `run.error` being the English paragraph, and a new ChatPane
render spec went red on the raw line appearing twice in one card. Both green here.
Verified in a real browser against a local runtime: the same failed run renders in
zh-CN and in en from one persisted event.

* test(daemon): stop the wiring suite pinning a racy CLI-version probe

CI run 32683047377 caught this on the previous head: the wiring test demanded
`0.38.0` inside `run.error` and got a version-less sentence, because
`getDetectedRuntimeVersions` is a process-wide cache that `probe()` clears
before each probe and refills after — a concurrent `/api/agents` refresh can
leave it empty exactly while a run is failing. Locally it always won the race;
on CI it did not.

The new assertions inherited that: `details.agentCliVersion` was pinned to an
exact string on both wiring cases. Assert the property that actually holds
instead — the version is either the one the daemon detected or absent, never
some other value — and say in the helper why presence is not asserted here.
Both copy paths (version present, version absent) stay covered
deterministically at the layers that own them: the payload unit suite and the
two web suites.

Making detection itself deterministic is a separate finding, still open on
this PR, and deliberately not fixed here.

* fix(daemon): let the classifier, not the wording, name a handshake refusal

Three review findings, one shape: the ACP handshake verdict was being decided
in more than one place, so the places disagreed.

1. The handshake reading sat inside `isAgentProtocolErrorText`, whose JSON-RPC
   arm matches only `json-rpc id N: Internal error`. A CLI that refuses
   `session/new` with `Method not found` or `Invalid params` skipped it
   entirely and was promoted to a retryable `fatal_rpc_error` on `child_close`
   — the no-auto-retry behaviour this PR claims never applied to it — or, once
   the payload rewrite had stamped its code, fell all the way through to
   `unknown` / `finalize`, which is exactly the untriageable bucket the module
   docblock says the rewrite must never produce.

   The wording an agent picks for its own rejection is not a signal. The
   handshake branch now sits AFTER every cause branch instead of inside the
   protocol one, so precedence is a fact of the control flow rather than a
   promise: whatever auth, rate limit, balance, upstream, prompt size, or CPU
   already claimed keeps it, and only an unexplained refusal reaches the last
   line. Ids 3 and up never enter it and keep the transient treatment they
   have always had.

2. `handshakeFailureNamesItsOwnRemedy` knew only the three
   `classifyAgentServiceFailure` classes, so every other cause the run
   classifier can already advise on was rewritten into the CLI-upgrade copy.
   A user whose content was too long read "your CLI version is incompatible"
   while the same run was filed as `prompt_too_large` / `reduce_context`.

   `isAcpCliSessionRefusalText` now IS the classifier — it runs
   `classifyRunFailure` over the text and reports whether that reading is the
   handshake refusal — so the card the user gets and the bucket the run lands
   in cannot prescribe two different fixes. The id-only helpers moved to
   `runtimes/acp-handshake-id.ts` so the classifier can read the id without the
   id-reader having to reach back into the classifier.

3. The guidance read the process-wide detected-version cache at FAILURE time,
   while `probe()` cleared each entry before re-reading it. Any `/api/agents`
   refresh overlapping a failing run decided whether the user was told which
   build refused them; CI caught it (run 32683047377) and the assertion was
   loosened instead of the race closed.

   Both halves are closed. A probe now publishes its reading once, at the end,
   and only when no later probe has already published, so the cache holds the
   newest COMPLETED reading rather than the state of one in flight. And the run
   freezes the version it is spawning with, so failure-time guidance reads the
   run and never the live cache.

Coverage is at the wiring layer, not just the predicates: the fake ACP CLI
gained named gates so a run's handshake and a concurrent `/api/agents` refresh
can be held open together and the failure built at the exact moment the cache
is mid-probe — deterministic, no sleeping on wall-clock timing. The wiring
suite's CLI-version assertion goes back to strict.

One deferral found by the existing suite and now pinned: a handshake-numbered
frame carrying an OS-level crash banner (Windows STATUS_ILLEGAL_INSTRUCTION
from the bundled opencode) reports a child that died, not one that refused, and
stays with the crash reading.

* test(daemon): describe the overlap the gate test actually constructs

The comment said the failure is built "at the precise moment the cache is
blank". That was the pre-fix window; the cache no longer goes blank mid-probe,
which is half of what the test proves. Say what it now holds open — a probe in
flight — and name both halves it exercises.

* fix(daemon): drop the duplicate detection import that blocks startup

`getDetectedRuntimeVersions` was imported twice — once standalone and
once inside the `./runtimes/detection.js` group — so the module has two
declarations of the same lexical binding. Node's ESM loader rejects that
outright with `Identifier 'getDetectedRuntimeVersions' has already been
declared`, i.e. the daemon could not boot.

The suite never caught it: vitest transforms through esbuild, which
tolerates the duplicate, so 179 tests passed against a module real Node
refuses to load. Verified by importing server.ts under Node directly —
SyntaxError before, gone after.

* fix(daemon): report the CLI build this run actually started, on both surfaces

A version-specific refusal (`AGENT_CLI_SESSION_REFUSED`) names the CLI build
that refused, so the user knows which one to change. Two things could make
that name wrong.

**The persisted event dropped it.** `send()` stores every run event on the
assistant message before emitting it, and `runSseEventToPersistedAgentEvent`
serialized only `detail` and `code`. The live card named the build; a reloaded
one fell back to the version-less sentence — exactly when the user comes back
to act on it. The mapper now lifts `error.details.agentCliVersion` out of the
frame, validated as a non-blank string, because `details` is a pass-through bag
that also carries the agent's own JSON-RPC `error.data`.

**The spawn-time capture could name a different executable.**
`getDetectedRuntimeVersions` answers per agent id and says nothing about which
binary the reading came from. A user who repointed the CLI after Settings last
listed agents (Settings writes `KIMI_BIN`; PATH changes) ran one build and was
told to change another. The capture now resolves through
`ensureDetectedRuntimeVersions` with the same `configuredAgentEnv` that
produced this run's `agentLaunch` — cached when the launch identity is
unchanged, re-probed through the same bounded `--version` read when it is not,
and taken before the spawn marks so preflight work is accounted to preflight.

Both are pinned end to end in `acp-handshake-failure-wiring.test.ts`: one test
reads the conversation back through the messages route a reloading client
calls, the other repoints `KIMI_BIN` between detection and the run and reads
the two fake CLIs' invocation logs to establish which child actually ran before
asserting what the failure reports.

* fix(daemon): keep a runtime that never started out of the CLI-refusal verdict

An ACP handshake rejection was decided on the JSON-RPC request id alone, so any
failure numbered 1 or 2 became "this CLI build cannot open a session; change
it". AMR is the population that reaches this path, and what it runs underneath
is OpenCode: when vela's bundled OpenCode child fails to come up it reports
`start opencode server: opencode exited before readiness: exit status 3` from
inside `session/new`, which arrives handshake-numbered while saying nothing
about the agent CLI's own build.

Two things went wrong at once. The user was told to replace a perfectly healthy
CLI, and the automatic retry that actually recovers a startup race was
withdrawn — `process_exit / agent_protocol_error / session_init`,
`retryable: false`, `install_cli`, with the ACP wrapper stamping
`AGENT_CLI_SESSION_REFUSED` on top. A startup race clears on the second
attempt; a CLI-build verdict never does, so the two must not collapse.

`isManagedRuntimeStartupFailureText` names the distinction: a CLI reporting
that a runtime IT manages never came up has named the moving part itself, so
its build is not the only variable left. The markers are vela's own wrapper
text, already trusted by `isCpuUnsupportedCrashText` for the crash-banner
sibling of this shape and now shared with it. Both deferrals now read the same
way: the handshake frame is the envelope, not the evidence — a child that DIED
(crash banner) or one that never STARTED (readiness) is not a refusal.

Also stops this suite's runtime from being a property of the host. The wiring
cases drive `GET /api/agents`, which probes every shipped runtime and resolves
binaries from PATH plus the machine's toolchain directories, so a host with
real agent CLIs installed turns each refresh into a dozen real spawns: ~2.8s
here, ~28s on a reviewer's machine — past the 20s test timeout for the
mid-refresh case, which waits on two. `isolateAgentDetection` points
`OD_AGENT_HOME` at an empty per-test directory, detection's own mechanism for
scoping the search, so the only CLI anything can find is the fixture's. Same
route, same probe path, same deterministic gates and overlap assertions; the
file drops from 109s to 14s and the mid-refresh case from 30s (timing out) to
1.2s.

* revert(daemon): drop the CLI-version half of the handshake-refusal card

The copy wanted to name the build that refused, and the chain that
followed ended in the launch path: the version had to be frozen before
the spawn, freezing meant awaiting a `--version` probe there, and an
await before `spawn()` sits after the run's only pre-spawn cancellation
guard. Stop clicked during that window finishes the run as canceled,
the probe then resolves, and the child is spawned onto an already
terminal run that nothing will signal.

The whole sub-branch goes rather than a second guard. What it bought
was `(0.38.0)` in a sentence that already read correctly without it;
what it cost was a run the user asked to stop starting anyway.

Removed: the pre-spawn `ensureDetectedRuntimeVersions` await,
`run.spawnedAgentCliVersion`, and the version half of
`agentFailureIdentity`; detection's probe generation guard;
`agentCliVersion` persistence, its `PersistedAgentEvent` field, its web
pass-through, and the `{version}` copy variant in all 19 locales.
`server.ts` no longer touches the run lifecycle at all — its diff is
four `send('error', ...)` call sites and one helper.

Detection's generation guard fixes a real, independent daemon bug (for
the length of every `/api/agents` refresh the daemon cannot report the
version it detected). It ships as its own PR, and naming the build in
the copy follows it.

Kept: the localized `AGENT_CLI_SESSION_REFUSED` card in 19 languages,
no auto-retry for a handshake-stage refusal, `run.error` exactly equal
to the agent's own line, auth/quota/upstream/managed-runtime-startup
each keeping their own verdict, and ids 3+ staying out of the branch.
The cases that asserted a version number now assert the same behaviour
without one, plus `not.toHaveProperty('agentCliVersion')` and a locale
guard against a stray `{version}` placeholder, so a partial re-land
cannot slip half of this back in.
2026-08-25 06:04:55 +00:00
f1a73f0d89 feat(od-next): structure-only layout primitives for the prototype task profile (#7327)
* feat(od-next): ship structure-only layout primitives with the prototype task profile

Blind evals and the pet-grooming runs showed the same layout defects over and
over — stacked spans that never became blocks, chrome height constants reused
as padding, authored copy crammed until it wrapped — while the prose rules that
forbid them sat unchanged in the prompt. The rules are now backed by something
the Build can copy instead of re-derive:

- `assets/task-profiles/prototype/layout.css`: a ~50-line, structure-only
  stylesheet (`@layer od-layout`, `:where()`, `od-` prefix) for stacked
  information, truncation/clamp of data text, media ratio, rails, and an
  optional screen skeleton. Declared as a prototype resource, staged into
  `.od-frames/` under the existing ownership manifest, and quoted into the
  stable request context as the `layout-primitives` fact on every prototype
  run.
- Prototype rule card 2.2.0: a "Variable-length text and stacked information"
  section that separates authored copy (rewritten to a budget, never
  truncated) from data text (truncate/clamp in lists, wrap in full on
  confirmation and detail screens), keyed to the real class names; the quality
  floor's layout bullet now points at the primitives.
- Core 2.1.0: the variable-length-text paragraph rewritten as actions
  (three fates before the text is written) instead of prohibitions.
- Run-finish observation `od_next_layout_primitives`
  (verbatim / modified / linked / absent) so the rollout can tell whether a
  model tier reproduces the block faithfully enough for inlining.

* fix(od-next): let aspect-ratio win on .od-media and pin two rule-card gaps

The first verification runs exposed one primitive bug and two recurring model
slips: `.od-media` lacked `height: auto`, so an `<img height=…>` attribute
kept its pixel height and the ratio never applied (tall cropped thumbnails);
cards built on `<a>` shipped with browser-default link styling; and product
tokens declared on an inner wrapper left sheet/dialog overlays mounted in the
shell's screen without a surface. The stylesheet and the rule card now say so.

* fix(od-next): keep .od-media at its intrinsic ratio; make cropping an explicit opt-in

Review: the fallback aspect-ratio of 1 plus an unconditional object-fit:
cover turned every unset .od-media into a square crop — contradicting the
core image-geometry rule (content-bearing images render their full frame;
cover is reserved for deliberate crops) and the primitive's own comment,
exactly on the media the rule card points at product shots. The primitive
now defaults to aspect-ratio: var(--od-ratio, auto) with no object-fit;
a uniform tile crop sets --od-ratio and adds .od-media-cover together,
and the commerce-card row of the rule card says so.

* fix(od-next): carry finish-time observations on run_finished, not the frozen baseProps

Review: od_next_device_platform*/od_next_layout_primitives were spread into
baseProps, which is built at run creation — before any deliverable exists —
and run_finished only spreads that frozen object. The daemon log printed the
observations while the analytics event never carried them, which defeats the
rollout signal these fields exist for. They now join finishedProperties,
read live from the run after the physical run resolves. (The device-shell
fields shipped with the same trap in the shells PR; fixed here on the same
lines.)

---------

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-08-25 03:58:46 +00:00
d03bf69d58 feat(standalone): complete Terminal exact lifecycle (#7319)
* feat(standalone): bootstrap terminal exact lifecycle

* feat(standalone): complete terminal exact lifecycle

* fix(ci): build standalone during postinstall

* fix(standalone): repair exact lifecycle recovery

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(standalone): make exact updates replay-safe

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(terminal): enforce replay-safe exact carrier

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(standalone): align active update replay state

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(standalone): preserve exact channel lifecycle

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-08-25 03:56:31 +00:00
0918d79fbd fix(daemon): make an ACP stage-watchdog stall attributable (#7310)
* fix(daemon): make an ACP stage-watchdog stall attributable

An ACP turn that goes silent is ended by attachAcpSession's own stage
watchdog, which fails the turn and then kills the child. Two telemetry
fields that exist to explain that terminal were wrong:

- `last_progress_age_ms` is contractually "near the inactivity ceiling on
  a stall". The daemon's ACP `send` wrapper stamped
  run.lastAgentActivityAt for every emission, including the watchdog's own
  error, so the daemon's timeout event became the last recorded progress
  and a 30-minute silence reported 664ms.
- `terminal_trigger` was unset, so the run surfaced only the child's exit
  code (AGENT_EXIT_130) - indistinguishable from a user interrupt.

Field case: run 14b04dd3-56b0-4d44-926b-db6cee3017ab (2026-07-28, OD
0.16.1, amr_cloud). 37.2 minutes, killed after ~30 minutes of silence,
reported last_progress_age_ms=664, no terminal_trigger. Triage read that
as "still working when killed" and chased the wrong window.

* fix(daemon): exclude host-synthesized ACP tool pairs from the progress clock

The previous commit stopped the ACP bridge's terminal `error` from stamping
`run.lastAgentActivityAt`, but that is only half the failure path. Every ACP
fatal path calls `flushOpenAcpTools(true)` *before* `send('error')`, and that
helper emits a synthetic `tool_use` + errored `tool_result` pair for each tool
the agent left open. Those ride the ordinary `agent` channel, so they stamped
the progress clock microseconds before the run finalized — a stall that died
with a tool in flight still reported a near-zero `last_progress_age_ms`.

That is the common stall shape, not an edge case: the new regression fixture
(open tool, then a silent stage timeout) reported 605ms against a 2000ms
silence, which is the same magnitude as the field incident's 664ms.

Carry the provenance out-of-band instead of in the payload: `send` takes an
optional `AcpEmissionMeta`, `flushOpenAcpTools` tags its pairs
`hostSynthesized`, and `runtimeEmissionCountsAsAgentProgress` excludes them
alongside the terminal error. The transcript, SSE wire, and Langfuse metadata
are unchanged — the pairs are still emitted, they just no longer count as the
agent making progress.

* test(daemon): name the emission that re-stamps the ACP stall progress clock

The progress-age assertion is green locally and red on CI, and a bare
"expected 16 to be >= 1600" cannot say which emission moved the clock.
Attach the run_finished summary and the persisted run event tail to the
failure so the next CI run identifies the culprit directly.

* fix(daemon): stop the progress clock when the daemon gives up on the turn

CI proved the previous two fixes were still partial. With the failure path's
synthetic tool pairs suppressed, an ACP stall on CI still reported
`last_progress_age_ms` of 17ms and 12ms, and the persisted event tail named the
culprit:

    ... agent:text_delta, error, agent:diagnostic, diagnostic:runtime_close

Emissions keep arriving AFTER the terminal error. The child's shutdown line on
stderr (`child.stderr.on('data')` stamps every byte), and the diagnostic
promoted from it, both land after the daemon has already decided to kill the
turn — and each one re-stamped the clock, so the reported age measured our own
teardown instead of the agent's silence. That ordering is timing-dependent,
which is why these specs were green locally and red on CI.

Freeze the clock at the verdict instead of hunting emissions one at a time.
`noteAgentActivity` stops advancing `run.lastAgentActivityAt` once this attempt
has a terminal verdict; everything else it does (tool-token TTL, watchdog
re-arm) is unchanged. The freeze is attempt-scoped: retries and the
resume-failed reseed re-enter `startChatRun` with a fresh closure, so the next
attempt measures its own silence. User cancel returns before the freeze and
keeps its own terminal path.

The flushed-tool marker is still required and not redundant: `fail()` flushes
open tools BEFORE it sends the error, so those pairs precede the freeze.

Adds a third regression case: the agent logs to stderr while shutting down.
Without the freeze it reports ~400ms against a 2000ms silence, deterministically.

* docs(daemon): tie the progress-clock predicate and freeze into one rule

* fix(daemon): let the ACP verdict own the attempt's terminal attribution

`freezeProgressClock` stopped the timestamp but left the outer chat inactivity
watchdog armed. `attachAcpSession.fail()` issues one direct SIGTERM and nothing
escalates it, so a child that lingers past the inactivity ceiling lets
`failForInactivity` fire on a run it does not yet consider terminal — the close
handler is what terminalizes — overwriting `terminal_trigger` with
`inactivity_watchdog` and emitting a second failure. The stall then reads as the
wrong clock, which is exactly the confusion `acp_stage_timeout` exists to
remove.

Red spec (a fixture that swallows SIGTERM, with the outer ceiling set just above
the stage timeout) reproduced it deterministically:

    AssertionError: expected 'inactivity_watchdog' to be 'acp_stage_timeout'
    run event tail: [... "agent:text_delta", "error", "error",
                     "diagnostic:runtime_close", ...]

Two `error` events, and the run finished under AGENT_EXECUTION_FAILED instead of
the child's own terminal code.

`retireAttemptOnAcpVerdict` now hands the attempt to the close handler under the
verdict the ACP bridge already reached: freeze the clock, retire the outer
watchdog, and escalate the teardown. The escalation is the necessary other half
— retiring the watchdog alone would leave a SIGTERM-ignoring child with nothing
to reap it. `scheduleForcedChildShutdown` captures this attempt's child, so a
retry that swaps `run.child` inside the grace window is unaffected, and user
cancel still returns before any of this.

* fix(daemon): stop every clock of an attempt that already has a verdict

Retiring the outer watchdog at the ACP verdict was not enough on its own.
`noteAgentActivity` only skipped the timestamp when frozen and still fell
through to the timer block, which clears and re-arms `inactivityTimer` — and
the raw `child.stderr` handler routes every late byte through that helper. So a
child that logs its shutdown line while ignoring SIGTERM re-armed the very
watchdog `retireAttemptOnAcpVerdict` had just cleared, and that timer firing
before forced shutdown reaped the child terminalized the run a second time.

The two existing fixtures missed it: one ignores SIGTERM without logging, the
other logs and exits. The gap is a child that does both. With the fixture logging
once (as a real CLI announces shutdown) rather than per signal — repeating it
would keep pushing the timers out and hide the race:

    AssertionError: expected 'inactivity_watchdog' to be 'acp_stage_timeout'
    run event tail: [... "agent:text_delta", "error", "stderr", "error",
                     "diagnostic:runtime_close", ...]

`error, stderr, error` is the whole story: the late stderr between the two
verdicts is what re-armed the clock.

`noteAgentActivity` now returns as soon as the attempt is frozen, so neither the
timestamp, the tool-token TTL, nor the watchdog can be restarted by a child that
is already being torn down. The new case also asserts exactly one terminal
`error` reaches the transcript, which is the property the trigger field depends
on.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>
2026-08-24 17:51:51 +00:00
lefarcenandGitHub fb26b5cb8a fix(runtimes): support the dsh release line users are actually served (#7339)
* fix(runtimes): support the dsh release line users are actually served

Two days after #7153 widened the version policy from one release candidate
to the `0.1.0-rc.N` line, upstream published `0.1.1-rc.1` and then
`0.1.1-rc.2`, and made the latter the `latest` tag. Scoping to `0.1.0-rc.N`
was still a pin, just a longer one: every user on what npm now serves is
told their CLI is untested, our installer hands out a version two releases
behind, and the connection component's peers do not resolve against the
host they land on.

That last one is not a warning. semver only lets a prerelease satisfy a
range when some comparator carries the SAME major.minor.patch tuple AND a
prerelease tag, so `>=0.1.0-rc.6` matches nothing on the 0.1.1 line — a
verified `satisfies('0.1.1-rc.2', '>=0.1.0-rc.6') === false`, the same rule
that made the original pin uninstallable.

Move all three to the 0.1.x line: the policy accepts any `0.1.N`, release
candidate or stable; both installers pin `0.1.1-rc.2` with the resolution
window reset to just after its publish time; the peers gain a comparator
for the new line.

This will expire again. A comparator per prerelease line is what semver
allows, so `0.1.2-rc.1` will not resolve until someone adds one, and
nothing in the repo notices when upstream moves. The check that catches
installer/policy drift only compares the two against each other.

* ci: watch upstream dsh releases instead of waiting for a bug report

Twice in one week `@deepseek-ai/dsh` published a new release line while
this repo kept pinning the old one, and both times the detection mechanism
was a person reporting that their install did not work.

The check added with the first fix compares our installers against our
agent def. Both are our own config, so it stays green precisely when they
are equally out of date — which is the state that shipped, twice. Nothing
here looks at the registry.

Add a daily scheduled comparison against npm's `latest` that separates the
two failures worth telling apart: `behind` means our installer hands out an
older version than the registry serves, everything still works;
`unsupported` means a user who has what npm serves is outside what we claim
to support, which is an "untested" warning in Settings and peer ranges that
do not resolve. The card names all three files that must move together,
because forgetting the peer ranges is the one that breaks an install rather
than warning about it.

Standalone and outside the merge gate, like nix.yml and docker-image.yml:
an upstream release is not a reason to fail someone's PR. The workflow runs
`self-check` before touching the registry so a broken checker is loud
rather than quietly in-sync.

* fix(ci,runtimes): stop the drift watch losing what it exists to report

Four defects from review, each of which would have made this quieter than
having built nothing.

The widened policy over-claimed. `/^0\.1\.\d+(?:-rc\.\d+)?$/` accepts
`0.1.2-rc.1`, which the peer ranges cannot install — so the daemon would
suppress the untested warning for a version whose companion fails to
resolve, which is worse than the warning and silent. Narrow prerelease
acceptance to the tuples and floors the peers actually carry; stable
releases on the line need no comparator and stay accepted. This also
retires an over-claim that predates the branch: `0.1.0-rc.2` and `rc.3`
are published and below the `>=0.1.0-rc.6` floor.

`e2e/tests/dsh-installer-version-policy.test.ts` now runs both authorities
over a version matrix and fails when the def accepts anything the peers
reject, so the two cannot drift apart again.

Importing the script ran the CLI. Collecting the new test fetched the live
registry — measured at 354ms with the verdict printed — and once drift
existed would have attempted a real Feishu post before any assertion. Move
the dispatch behind a direct-entry check and prove it with a test that
stubs fetch and re-imports.

Feishu answers a rejected webhook with HTTP 200 and a nonzero code, so
treating every 2xx as delivered discards the alert and logs success doing
it. Mirror the landing notifier's contract: code 0 or absent is delivered,
429/5xx/9499 retry, anything else raises.

The workflow chose the landing webhook but signed with an unpaired secret,
so a bot that verifies signatures would reject the card. Pair them the way
the landing workflow does, and pin the mapping in the topology test.

* test(e2e): check each dsh peer range on its own

The compatibility guard scanned the whole manifest and merged every
comparator into one tuple table, so a single updated peer answered for the
other four. A bump that updates one range and forgets the rest — the
realistic way this goes wrong — read as compatible, which is the partial
state the guard exists to catch.

Parse `peerDependencies`, keep the `@deepseek-ai/dsh-*` entries, and require
every one of them to admit a version before calling it installable.
`cordis` is excluded: it has its own version line and never moves with dsh.

Two cases cover it: a fixture where one peer carries the new comparator and
the others do not, and the floor itself, since a comparator for the right
tuple still rejects a candidate below it. Verified against the real
manifest by downgrading four of the five ranges, which reds the matrix with
`expected [ '0.1.1-rc.1', '0.1.1-rc.2' ] to deeply equal []`.
2026-08-24 17:24:12 +00:00
lefarcenandGitHub f92c2d957c fix(viewer): stabilize Electron HTML previews (#7336) 2026-08-24 14:12:03 +00:00
lefarcenandGitHub d85e78ab7d fix(runtimes): reach a working agent CLI past a broken shim (#7153)
* fix(runtimes): reach a working agent CLI past a broken shim

Resolving an agent's name on PATH only proves a file exists there, never
that it runs. Detection stopped at the first hit, so a wrapper orphaned by
a half-finished `npm i -g` shadowed a perfectly good CLI of the same name
in a later search directory — and because the not-invocable branch dropped
the resolved path, the picker (which renders an unavailable agent only when
it carries one) hid the agent entirely. The user saw "scan found nothing"
with no way to act.

Detection now walks past candidates that cannot be executed, and reports
the path it tried when every candidate fails, so the row stays actionable.

Also on this chain:

- `@open-design/dsh-runtime` pinned its DeepSeek Harness peers to one exact
  release candidate, so it became uninstallable the moment upstream shipped
  the next one. Peers now take ranges. `scripts/guard.ts` gains a narrow,
  logged exemption for external-host plugin manifests — their peers describe
  a host this repo neither installs nor controls, which is the one case
  where an exact pin is wrong rather than reproducible.
- The DSH installers pinned only the entry package while its ~190 siblings
  used caret ranges over prerelease floors. Once a newer release candidate
  existed, the transitive tree floated onto it and its mutually exclusive
  peer sets sent npm into an ERESOLVE backtrack that never converged. Both
  installers now freeze the resolution window alongside the pin.
- The rescan notice counted agents the list never renders, so it announced
  "3 available" above two rows.

* fix(runtimes): make detection's chosen executable the one spawn sites use

The candidate walk taught detection to skip a binary it cannot execute, but
every other resolution — chat, the connection test, the memory summariser,
companion install — called `resolveAgentLaunch` with no skip list and got
back the first file that merely exists on PATH: the broken shim detection
had just rejected. Settings advertised the agent as installed while each
turn exec'd the wrapper, so the fix only ever reached the picker.

Detection is the only stage that spawns anything, so it is the only one that
learns which candidate works. It now publishes that winner, and resolution
prefers it over a fresh first-hit walk. An explicit `*_BIN` override and a
packaged built-in still outrank it, a winner whose file has since vanished
is ignored, and detection clears the entry before each pass so a rescan
after a repair never resolves against a stale one.

This restores the invariant the file already documented — detection probes
the exact path the runtime will spawn — which the walk had broken.

Reported by @mrcfps in review.

* fix(runtimes): keep the remembered executable inside the live candidate set

The winner map made resolution stop being a pure function of the current
environment. It only checked that the remembered file still existed, so a
path learned in a richer environment survived an emptied PATH, a sandboxed
OD_AGENT_HOME, and any other narrowing of the search — resurrecting a binary
the caller could no longer see.

That is not just a test artifact: `GET /api/integrations/vela/status` must
answer `amr-runtime-unavailable` when the vela binary cannot be resolved, and
it started answering "ready" instead, because an earlier detection in the
same process had remembered a path.

The memory now reorders candidates rather than introducing them: it applies
only when the live search already offers the remembered path. An emptied
PATH, a sandbox, and an uninstalled CLI all keep meaning "not found", while
a broken shim shadowing a working binary is still resolved to the winner.

This also settles the scope question raised in review — no separate cache key
is needed, because the candidate set already carries the resolution scope.

* fix(runtimes): treat a launcher whose target is gone as not invocable

A stale global npm wrapper on Windows is a `.CMD`, which OpenDesign runs
through `cmd.exe /d /s /c`. When the package behind it has been
uninstalled, cmd.exe starts, node starts, and only then does node fail to
load the script the wrapper names — a plain exit 1 with MODULE_NOT_FOUND
on stderr. None of the spawn-level signals the classifier looked for
(ENOENT / EACCES / ENOTDIR, exits 126 and 127) can occur along that
chain, so detection read the dead wrapper as a healthy CLI that merely
disliked `--version`, stopped the candidate walk on it, and never reached
the working `dsh.CMD` later on PATH.

Classify invocability from the launcher's own vocabulary as well: cmd.exe's
9009 (the Windows analogue of POSIX 127), node's missing-module report, and
cmd.exe / PowerShell's missing-command and missing-path reports. Exit status
alone still means nothing — a CLI that runs and exits non-zero is a real
answer from the right binary and keeps its place in the walk.

Drop the win32 early return from every case in the fallback suite and write
fixtures per platform, so `dsh.CMD` resolution through PATHEXT and the
cmd.exe wrapping are exercised natively. The orphaned-wrapper regression is
reproduced on every platform — the defect is a classification bug, not an OS
API one, and the daemon suite only runs on Linux in CI.

* fix(runtimes): carry forward the dead paths, not detection's winner

Detection published the candidate it settled on and resolution preferred
it over the first live PATH hit. That pinned the daemon to one binary for
its whole lifetime and let it silently outrank the caller's own PATH: a
CLI that became visible earlier afterwards — a fresh install, a version
manager swapping shims, a caller resolving under its own environment —
could no longer be reached.

The daemon suite caught it on a machine that has a real `vela` installed:
`chat-route` and `connection-test` inject a fake agent CLI ahead of PATH
per case, and the remembered winner outranked it, so 15 cases spawned the
machine's real CLI instead of the fixture. CI never saw it because its
runners have no such CLI to remember.

Carry the proven-dead paths forward instead. Skipping them keeps every
candidate still standing in plain PATH order, while chat, the connection
test, the memory summariser, and companion setup still avoid the shim
detection rejected — which is the whole point of publishing anything.

* docs(runtimes): say why detection keeps its own launcher-error list

`isCliNotInstalledText` in run-failure-classification.ts reads similar
output, and the next reader will reasonably ask why the two are not one.
They answer different questions: that one buckets a finished run for
telemetry, where over-matching costs a mislabelled event, while this one
decides whether to abandon a binary mid-resolution, where over-matching
sends a working CLI's user to a different install.

* fix(runtimes): accept the dsh release line, not one release candidate

This branch moved the installers to 0.1.0-rc.8 and left the agent def
naming 0.1.0-rc.6 as the only supported version, so following our own
one-line install produced "0.1.0-rc.8 has not been tested with this
OpenDesign build" in Settings on a clean machine. That is the same
pinning defect the installers had, one layer up.

`dsh` publishes a stream of release candidates whose sibling packages
peer-require each other, so the version our installer hands the user
moves with upstream. Version policies can now declare the release line
they accept alongside the exact versions exercised, and DeepSeek Harness
declares its own. A version off that line still warns — the point is to
stop pinning, not to stop checking.

Add a cross-boundary guard for the drift itself: the installers and the
agent def cannot import each other, so `e2e/tests` reads both and fails
when an installer pins a version the daemon would not accept.

* docs(contracts): correct what shim-broken now means

The reason's docstring said "exit 126/127", which stopped being the whole
story once detection learned to read a Windows wrapper's stderr: a `.cmd`
starts an interpreter successfully and reports the missing target there,
so no POSIX exit code appears at all.

* test(runtimes): cover the launcher signatures no fixture can produce

`launcherTargetMissing` recognises six ways a launcher says it never
reached its target, and until now two of them had any coverage — the two
a POSIX shell can print. cmd.exe's wording, PowerShell's, and exit 9009
were load-bearing for the Windows fix and untested anywhere.

A fixture cannot close that gap. POSIX masks exit statuses to eight bits,
so a shell asked for 9009 reports 49, and /bin/sh does not speak cmd.exe.
The daemon suite runs on Linux in CI, so driving the classifier directly
is the only way the merge gate can catch a typo in a signature the fix
depends on for the platform the bug came from.

Extract the catch arm verbatim as `classifyVersionProbeFailure` — same
branches, same order, no behaviour change — and feed it the rejection
shape `promisify(execFile)` actually produces. Verified by mutation:
deleting the 9009 branch reds one case, deleting the stderr branch reds
six.

The suite also pins the constraint from the review by name: exit 1 with an
ordinary argument complaint, with no stderr, or with wording that belongs
to run-failure telemetry ("not installed") all stay `spawned`. That last
one is why detection keeps a narrower list than `isCliNotInstalledText`.
2026-08-24 13:04:31 +00:00
Marc ChanandGitHub 18273c01b8 fix(packaged): recover from stale web sidecars (#7279)
* fix(packaged): recover from stale web sidecars

* fix(packaged): stop stamped web owner before socket takeover

Unlinking only the unresponsive web socket left the old Next process
alive. Its later JsonIpcServer.close() could delete the replacement
pathname. Stop the stamped owner through platform primitives first.

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

* fix(packaged): strip handoff parent pid and skip unsafe web unlink

Strip OD_TOOLS_DEV_PARENT_PID from the legacy payload desktop spawn
environment so the replacement does not exit after waiting for the
outer PID. Skip web socket unlink when a stamped owner is still
running after stop.

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

* fix(packaged): skip web unlink when process discovery fails

listProcessSnapshots returns [] on enumeration failure, so an empty
table is not proof that no stamped owner exists. Leave the live socket
in place unless discovery succeeded and every matched owner is gone.

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

* fix(daemon): hold parent monitor until handoff journal commits

Packaged daemons now exit when the outer Electron dies. Desktop
SHUTDOWN acks before asynchronously exiting, so the parent can
disappear while armed handoff, attempt, and runtime writes are
still pending. Hold the parent-death exit across that window.

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

* fix(daemon): defer SHUTDOWN until handoff journal commits

Packaged beforeShutdown still sends sidecar SHUTDOWN after desktop
acks, and that path ignored the parent-monitor hold. Wait for the
same release before exiting so handoff.json, attempts.json, and
runtime.json can finish.

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

* fix(daemon): defer SIGTERM until handoff journal commits

Packaged closeManagedChild waits five seconds then stopProcesses()
sends SIGTERM. That handler still exited immediately and could cut
off the held journal writes. Route signal shutdown through the same
hold as SHUTDOWN.

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

* fix(daemon): hold parent monitor before payload spawn

A crash after the replacement emits spawn but before the journal hold
lets the parent monitor exit the daemon while handoff.json is still
prepared. Acquire the hold before spawn and release it on failure.

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

* fix(packaged): wait for deferred handoff shutdown before SIGKILL

Daemon SHUTDOWN now reports deferred:true while the handoff journal hold
is active. closeManagedChild then waits a bounded 30s for self-exit
before stopProcesses, so SIGKILL cannot cut off the three sequential
journal writes. A POSIX regression exercises the real stopProcesses
path and checks all three files commit before forcedPids.
2026-08-24 09:31:54 +00:00
open-design-crew[bot]GitHubCheemslefarcenCheems黄桃open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>NJUHuahihijack111
7320f9b8ab feat(daemon): make OD Next the default design strategy (#7016)
* refactor(observability): isolate legacy run exporter

* refactor(daemon): extract internal run creation service

Route /api/runs and compatibility /api/chat through one daemon-owned create, claim,
resume, and start seam. Preserve HTTP responses, SSE timing, session bookkeeping,
and durable Run formats.

Validated with daemon tests/build/typecheck, workspace guard, and workspace typecheck.

* test(plugin-runtime): freeze OD Next strategy fixture boundaries

Add exact pre-Build route and stage cases plus deterministic deletion-boundary mutations without shipping an evaluation harness.

Preserve the current official and community fallback pipeline and composed verification/critique prompt sections as golden witnesses.

Validated with plugin-runtime tests and types, focused daemon prompt composition, daemon types, guard, and workspace typecheck.

* fix(daemon): finalize terminal telemetry delivery

Persist a versioned delivery record so normal failures stop after bounded retries
and only an unfinished crash window resumes on restart.

Reuse the persisted identity across Vela and Relay transport, isolate stable
direct Langfuse event ids by delivery purpose, and log only redacted sink data.

Validated with focused daemon and contracts tests, workspace typechecks, and
guard. The full daemon suite had one timeout; its four-test file passed alone.

* feat(plugins): add OD Next strategy scenario and contracts

Ship the V2 strategy package as inactive bundled content and validate its contracts without changing ordinary plugin manifest compatibility.

Keep bundled strategy bytes out of the catalog and generic apply path until a later hash-gated activation owner binds them.

* feat(observability): add normalized agent observation contract

Define a pure versioned contract for identity, Prompt, usage, timing, and status evidence with explicit availability and per-value provenance.

Add a structured main-Run compatibility adapter that preserves unknown facts, separates clocks, and omits provenance-ambiguous derived totals without changing legacy export behavior.

* feat(plugins): bind OD Next strategy content identity

* feat(daemon): persist OD Next strategy task chains

Add the daemon-owned SQLite truth for immutable strategy task identity, ordered physical Run mappings, canonical Plan Contracts, revision-CAS transitions, and terminal reconciliation.

Pin task-owned Snapshots and exclude their durable references from pruning so mixed GC sweeps remain safe.

Validated with 103 focused daemon tests, contracts tests/typecheck, daemon and workspace typechecks, and guard.

* feat(daemon): gate OD Next runtime capabilities by fixtures

Add exact CLI/adapter/fixture tuple resolution, stable capability snapshots, and offline
replay graph invariants for the four initial runtime paths.

Keep production registry empty until sanitized real fixtures exist; synthetic evidence and
version drift remain unknown.

Validated with contracts tests, focused daemon and e2e boundary suites, guard, and
workspace typecheck.

* feat(daemon): add hash-gated OD Next prompt recipe

Compose the bundled strategy from validated content, one Task Skill, and the exact stage policy.
Fail closed on package, profile, pipeline, or atom drift while preserving ordinary quality paths.

Validated with focused tests, package typechecks, workspace typecheck, and guard.

* feat(observability): aggregate OD Next task observations

Build stable task, Run, Child, model, and tool hierarchies from durable StrategyTaskExecution mappings and normalized facts while keeping missing coverage explicit.

Map task aggregates to legacy ingestion behind the consent gate. Preserve owner/exclude hierarchy while accounting usage once, enforce kind-specific Prompt boundaries, and accept absolute time only from one Unix-epoch evidence record.

Validated with 228 focused daemon tests, 353 contracts tests, daemon/contracts and workspace typechecks, guard, and the task harness.

* feat(daemon): collect bounded Codex child evidence

Read declared Codex rollout sessions without scanning arbitrary homes.
Normalize verified child facts while redacting prompts and preserving Run semantics.

* feat(daemon): add OD Next planning coordinator

Add a daemon-owned planning reducer, deterministic Resolver and Preflight facts, and a stream-safe machine protocol boundary backed by the durable task store.

Lock one clarification and one serialization-only repair across restart, preserve the Plan hash, and keep ordinary Run text unchanged.

* feat(daemon): collect native OpenCode child evidence

* feat(observability): add Langfuse OTLP task exporter

Map normalized task observations to complete OTLP/JSON v4 spans with stable parent context, allowlisted metadata, usage accounting, and bounded terminal delivery.

Fail closed on drift across root and operation input, model, timing, type, hierarchy, status, and complete usage provenance while dual remains a local-only OTLP shadow.

Keep legacy rollback, consent gates, and X5 live self-host verification boundaries intact.

* feat(daemon): normalize Claude native child evidence

Collect matched Claude Task sidechain lifecycle facts without changing main stream turn, stdin, or error bookkeeping, and adapt them to provider-neutral observations with unavailable Prompt and usage evidence.

Bind session, Task id, and parent identity immutably; poison rebinding, session drift, and conflicting terminals so ambiguous evidence cannot reach L2. Keep production capability unknown until X1 supplies an exact-version sanitized real fixture.

* feat(daemon): consume versioned Vela child evidence

Negotiate schema v1 only on AMR and enforce run-scoped start-to-terminal graph, status/source coherence, and exact usage source/completeness bindings. Emit allowlisted Normalized child facts without Vela private I/O.

Pin the unpublished producer golden for offline compatibility while keeping production capability unknown until the X1/X3 evidence gates are satisfied.

Validated with 147 focused daemon tests, 353 contract tests, daemon/workspace typechecks, guard, and the task harness.

* feat(daemon): automate OD Next simple production

Connect parsed OD Next terminal output to the durable coordinator, claim clarification, repair, and production Runs atomically, and start continuations only after the source Run ends.

Converge task failure and cancellation before physical end, require daemon-owned execution facts and canonical delivery, and keep ordinary Runs compatible.

Validated with focused daemon route and coordinator tests, contracts tests, daemon and workspace typechecks, guard, and the task harness.

* feat(daemon): enforce verified complex production

* feat(web): follow OD Next task chains across Web and CLI

Persist daemon-issued task handles and visible predecessor-prefix boundaries, then follow projected active Runs through one terminal outcome in Web and od run.

Normalize reloads to daemon activeRunId before subscribing, transfer controller ownership atomically across successors, discard stale cursors, and preserve visible prefixes without replaying predecessor machine payloads.

Validated with focused Web and daemon tests, contracts tests, workspace typecheck, guard, diff checks, and the task harness.

* feat(daemon): roll out OD Next task observability

* feat(daemon): add controlled OD Next rollout

* fix(daemon): align OpenCode and Vela evidence tuples

Pass GPT-5.6 Sol's verified high variant to OpenCode, pin native evidence to 1.18.18, retain locally recorded success and failure seeds as explicitly unattested, and align the Vela consumer golden to the local versioned producer.

Validated with 5 focused daemon files (175 tests), daemon and workspace typechecks, guard, the real local Vela ACP Sol smoke, and a minimal test-relay telemetry write.

* fix(daemon): honor OpenCode model variant capabilities

Parse the live verbose OpenCode catalog per model and forward only variants advertised by the exact detected binary. Keep fallback and one-off OPENCODE_BIN connection tests on the base model when variant support is unknown.

Record machine-checkable OpenCode 1.18.18 root, tool, child, recovery, cancel, timeout, and resume evidence without promoting the unattested seed into the production capability registry.

Validated with focused runtime and connection tests, host process-group and watchdog tests, contracts tests, daemon/workspace typechecks, guard, and an isolated SIGKILL delivery-recovery exercise.

* feat(daemon): add OD Next analytics operations report

Reduce normalized task aggregates into source-aware operations metrics without
copying the Langfuse dashboard or exposing user trace links.

Preserve main/Child accounting, stage-scoped coverage, unavailable facts, and
external-signal-only quality inputs.

Validated with focused and contract tests, the full daemon suite, guard,
workspace typecheck, and exact-trace Langfuse readback.

* feat(observability): complete child prompt and runtime provenance

Export bounded redacted child-injected Prompt content and exact CLI, companion, and
adapter versions through task-level legacy and OTLP mappings.

Register the OpenCode 1.18.18 best-effort replay tuple while keeping unknown versions
and incomplete Codex evidence fail closed.

Validated with 353 contracts tests, 196 focused daemon tests, daemon/workspace
typechecks, and guard.

* fix(daemon): recognize Codex child failure terminals

Parse Codex 0.147.0 Child task_complete.error as the runtime-owned failed terminal, honor session_meta id over its legacy root session_id, and map interrupt_agent aborts to canceled.

Add a redacted seven-path best-effort replay and exact Codex capability tuple. Unknown versions remain fail-closed and ordinary Codex runs are unchanged.

Validated with 92 focused daemon tests, 353 contracts tests, daemon and workspace typechecks, guard, and the 25-unit task harness.

* fix(daemon): report verified child runtime versions

Use versions observed from resolved CLI probes or Vela's ACP handshake when annotating Child observations. Correct Native OpenCode's allowlist key, add Claude and Vela/OpenCode companion provenance, reject Vela probe/handshake disagreement, and ignore the legacy 0.0.0 placeholder.

Validated with 252 focused daemon tests, daemon and workspace typechecks, guard, the task harness, and a real local Vela --version/-v/ACP initialize build smoke.

* test(observability): record Vela child evidence matrix

Add a sanitized provider-backed Terra replay for main, tool, child success, child failure with parent recovery, cancel, timeout, and cross-process resume. Keep the unpublished Vela tuple outside the production registry while pinning its exact candidate and OpenCode companion evidence.

Validated with focused Vela consumer/capability tests, daemon typecheck, workspace typecheck, guard, and the task harness.

* feat(daemon): enable OD Next production profiles

Make prototype, presentation, marketing, and HyperFrames the exact global OD Next production set while preserving explicit off/observe, user plugin authority, and fail-closed runtime capability gates. Feed runtime-owned profile, route, output, input, and capability facts into the planning contract and keep all other artifact kinds on ordinary Open Design.

Use bounded executable-scoped CLI version probes so headless and restarted daemons can resolve exact tuples without a prior agent-list request or cross-binary cache contamination.

Validated with four provider-backed Terra CLI chains, focused daemon/contracts/plugin-runtime suites, daemon and workspace typechecks, guard, and the 25-unit task harness.

* feat(daemon): classify OD Next exact agent inputs

Route prompt contributors through one daemon-owned choke point and version their final-text, semantic-fact, transport, and exclusion ownership for the forthcoming canonical bundle. Send OD Next continuation stages directly through native sessions without rewrapping or re-seeding stable instructions.

Validated with focused daemon and contracts tests, daemon/workspace typechecks, and guard.

* fix(daemon): close OD Next input ownership gaps

Bind semantic request facts to their real daemon-system or request producers, cover the DSH profile exact-text transport, and reject lookalike prompt-bundle root names.

Validated with focused exact-input, production-server and DSH tests, daemon typecheck, task harness, and guard.

* feat(daemon): emit canonical OD Next prompt bundles

Add strict pure TypeScript Bundle and request-Turn serializers, then route OD Next request and continuation text through their versioned XML contracts.

Split Web prior transcript from the current turn without text subtraction, keep mutable Skill and attachment inputs explicitly pending, and preserve ordinary prompt behavior.

Validated with focused contracts, daemon and web tests, the real server fixture, package and workspace typechecks, and guard.

* fix(daemon): align OD Next bundle input ownership

Register the real canonical Bundle contributors and keep current-turn, prior-transcript, pending-fact, and research-query ownership explicit at the production seam.

Reject continuation task_run_index zero and cover Web framing, ordinary compatibility, fail-closed contributor drift, and real server prompt capture.

* feat(daemon): freeze OD Next user skills

Capture explicit Skill bodies and audited side files before task claim, persist verified task-owned bytes, and replay only the frozen package for OD Next runs.

Keep legacy single-Skill CLI requests and ordinary live staging compatible while adding Web/CLI multi-Skill coverage and fail-closed snapshot checks. Validated with focused server, contract, CLI, Web, typecheck, and guard suites.

* fix(daemon): harden OD Next skill snapshots

Require an explicit frozen package for every mapped OD Next task and fail corrupt task state before any live Skill staging.

Use bounded no-follow descriptor reads with race checks, preserve legacy CLI compatibility, and make materialized roots collision-resistant. Validated with focused, real-server, workspace typecheck, and guard suites.

* feat(daemon): freeze OD Next task input snapshots

Freeze OD Next attachments and canonical configuration before the atomic run/task claim, then route runtime access through durable task-scoped snapshots while preserving ordinary attachment behavior.

Validate immutable identities, transport references, continuation propagation, tamper failure, and the four production task types with focused contract and daemon coverage.

* fix(daemon): isolate OD Next runtime input projections

Keep canonical task snapshots daemon-only, rebuild a read-only per-Run projection, and deliver frozen document resources through ACP or OD_TASK_INPUT_DIR.

Harden source and snapshot reads with no-follow same-fd identity checks and bounded caps. Validated with focused runtime tests, contracts, daemon/workspace typechecks, guard, and the task harness.

* fix(daemon): harden OD Next snapshot transport boundaries

Reject intermediate symlinks and realpath escapes while loading canonical snapshot files, including identical-byte replacement targets.

Preserve ordinary ACP duplicate image ordering while suppressing only OD Next resource/image cross-duplicates. Validated with focused tests, daemon typecheck, guard, and the task harness.

* fix(daemon): preserve structured OD Next snapshot failures

Defer frozen Skill package errors from the Task 04 preclaim lookup to the existing post-claim failure boundary so mapped Runs remain durable JSON failures without orphan input snapshots.

Validated with the combined real-server repair, missing-package, and tampered-package regressions.

* fix(daemon): keep OD Next continuation cwd stable

Separate authoritative project cwd resolution from best-effort file inventory and tolerate entries deleted between directory enumeration and stat.

This keeps native-session identity stable while frozen task inputs replace live attachments during continuation.

* feat(daemon): persist exact OD Next prompt inputs

Persist the canonical first-turn Bundle and every deterministic continuation Turn with UTF-8 and SHA-256 identities, then send only those validated texts through runtime transports.

Fail closed on mapped task drift, tamper, legacy NULL identities, missing mappings, and cold continuation while leaving ordinary Runs on the existing live composer.

Validated with the 15-case automatic server acceptance twice, 223 focused daemon tests, daemon typecheck, guard, and the task harness.

* feat(daemon): observe exact OD Next prompt inputs

* feat(daemon): bind and observe Claude native subagents

Register the replay-verified Claude 2.1.233 tuple, bind complex Build Packages to daemon-issued native Agent handles, and derive completion only from durable child lifecycle evidence.

Export bounded parent-tool, Child Agent, and Child-tool spans through the existing legacy and OTLP task hierarchy without exposing raw tool inputs, outputs, provider ids, paths, or credentials.

Validated with focused contracts and daemon suites, the 17-case real-server chain, workspace typecheck, guard, and the 26-unit task harness.

* fix(daemon): repair OD Next rollout authority and controls

* fix(daemon): harden OD Next public canaries

* refactor(daemon): extract OD Next prompt preparation

* test(daemon): align plugin fixture with project schema

* test(e2e): assert daemon-owned prototype routing

* fix(daemon): restore OD Next task observability

Default eligible OD Next tasks to privacy-aware Langfuse hierarchy delivery through relay or direct sinks, with a versioned safe quality projection.

Persist lightweight representation ownership before suppressing mapped Runs, retain best-effort startup retry semantics, and preserve single-Run compatibility when Task delivery is not eligible.

Adopt the active payload relay for historical packaged launchers and cover the real sidecar environment handoff.

Validated with the full daemon suite, focused contracts/packaged/e2e/tools-pack suites, workspace guard, and workspace typecheck.

* fix(daemon): make OD Next capability fallback invisible

Admit capability-certified runtime adapters across CLI version drift while keeping recorded fixture versions as auditable evidence and reserving a narrow incompatibility denylist.

Fall back before Run claim on preparation failures, keep continuation bound to the frozen capability snapshot, and report truthful child coverage across Langfuse legacy and OTLP without replaying an active task.

Validated with focused contracts and daemon suites, the automatic server suite, pnpm guard, pnpm typecheck, daemon typecheck, LSP diagnostics, and git diff --check.

* fix(strategy): isolate OD Next automatic task routing

* fix(daemon): persist blocked strategy-task attribution

Every blocked strategy task now stores its gate reason codes and the
agent-visible turn text (blocked_reason_codes_json / blocked_visible_text)
so operators can diagnose a blocked task from the store alone. Covers the
coordinator blockTask gate, the automatic-continuation fail-closed path,
and startup run-terminal reconciliation.

* fix(daemon): accept form-only clarification turns by deterministic inference

A first Full-Plan request turn that renders exactly one question form and no
machine-protocol block has exactly one valid protocol meaning: outcome
clarification_required with an unlocked execution mode. The coordinator now
recovers that state deterministically instead of dead-ending the task as
blocked, stamps od_next_protocol_runtime_state_inferred into the accepted
state's reasonCodes for attribution, and keeps every ambiguous shape (extra
forms, recovered plan blocks, later stages, spent clarification budget,
any other protocol issue) fail-closed. This closes the observed field
failure where the agent's first reply produced a discovery form, the task
went blocked, and the user's form answer then failed with 409
STRATEGY_TASK_STATE_MISMATCH.

* feat(contracts,daemon): project blocked strategy-task attribution to clients

A task the OD Next protocol gate settles as blocked persists its
attribution (reasonCodes + agent-visible text) since 8bc7805e62, but the
client-facing StrategyTaskProjectionV2 dropped it, so the UI could not
tell a blocked task from any other failure. Add the optional
blockedContext field to the projection contract (additive, strict shape
mirroring the task store) and have projectStrategyTask carry it through
every run-status / SSE assembly point, only on a blocked outcome.

* fix(web): terminate question-form interaction when a strategy task is blocked

A blocked OD Next task is a sticky terminal verdict, but the web client
kept the turn's discovery/clarification form fully submittable; the
answer then 409'd with STRATEGY_TASK_STATE_MISMATCH and surfaced only
the generic 'Task failed' card.

- ChatMessage gains additive strategyTaskBlocked / strategyTaskBlockedText
  fields; strategyBlockedMessageFields derives them from any terminal
  blocked projection (visibleText preferred, trimmed, else null).
- streamViaDaemon/reattachDaemonRun expose onStrategyTaskSettled with the
  terminal task projection; ProjectView stamps the blocked verdict onto
  the assistant message on live settle, on reattach settle, and on the
  reattach status probe, and persists it so it survives reloads.
- AssistantMessage disables the inline question form for a blocked turn
  (including the auto-continue countdown) and renders the gate's visible
  text - or a generic localized notice - under the form.
- resolveRunFailureUi maps STRATEGY_TASK_STATE_MISMATCH to dedicated
  halted-task copy instead of chat.runError.title.generic.
- New i18n keys (title, 409 message, form notice) added to types.ts and
  all 19 locales.

Blocked-state interaction shape is a minimal safe default pending a
product decision: disable submission and explain, no auto-retry, no
degraded re-run.

* fix(daemon): normalize premature clarification execution mode

The observed 2/2 field failure on vague first prompts: the agent emits a
well-formed Runtime State block for clarification_required but predicts
"executionMode": "simple" alongside it. The mode is daemon-locked only
after clarification resolves, so the prediction carries no authority — yet
strict schema validation classified the whole block as
od_next_protocol_runtime_state_invalid_schema (non-repairable) and
dead-ended the task. The protocol stream now discards exactly that field
before validation, records the normalization for attribution, and the
prompt recipe gains an explicit rule plus a clarification-shape Runtime
State example so agents stop copying the plan_ready example's mode.

* fix(strategy): declare the runnable-entry deliverable convention to agents

The deliverable validator requires a root-level runnable entry (root
index.html for html deliverables), but neither the frozen prompt bundle nor
the production continuation ever stated that convention — the agent was
told only 'editable prototype source with a stable runnable entry', built a
framework scaffold in good faith, and the completed task then blocked with
od_next_canonical_deliverable_invalid. The production continuation and the
prototype Task Skill now state the root-entry convention and forbid
unplanned framework/dependency scaffolds, so the contract the daemon
validates is the contract the agent was given.

* fix(daemon): stop the rollout only for real machine-contract failures

rolloutStopSignalForBlockedContinuation mapped every reason code containing
'protocol' to machine_contract_leak, whose stop mode is a hard instance-wide
off that survives restart. Two vague prompts were therefore enough to
silently disable OD Next for the entire daemon: the agent emitted a
clarification runtime state carrying a premature executionMode, the task
blocked, and every later request in the daemon fell back to the legacy
plugin with no UI signal and no automatic recovery.

The machine block never reaches the user — the protocol stream suppresses
undelimited and oversized bodies — so an agent turn that omits, duplicates,
or mis-populates a block is a per-task defect, already fail-closed for that
task and now attributable from the store. Only the two block-delimiting
failures keep raising the leak signal.

* fix(strategy): state the entry-resolution constraint without inventing a product rule

The previous wording told agents that a prototype's runnable entry must be a
root index.html and that framework scaffolds are forbidden. Neither is in the
product spec, which says only that the user must be able to open and use the
deliverable from a real entry point, and neither matches the validator, which
resolves an entry through root index.html, then a single root-level html file,
then a single kind-matching file. That turned one fallback branch into a
product constraint the agent then had to obey.

Both surfaces now state the resolution rule the daemon actually applies and
why a delivery is rejected without it, and take no position on whether a
prototype may be an application project. Whether the validator should also
resolve framework-project entries is a product decision and stays open.

* fix(strategy): assemble the OD Next prompt bundle as an XML tree

The first-turn payload was an XML envelope around four flat CDATA blobs,
with one of them holding 25.3KB of joined Markdown (89% of the bundle).
Three layers each threw structure away: the contract typed the bundle as
four strings, the daemon joined 15 individually-named contributors with
'\n\n---\n\n', and the recipe re-joined 10 sections while demoting the
stage/atom map to Markdown headings. The result inverted heading levels in
7 of 46 places and put a per-task UUID 1,268 bytes into the payload, which
pushed 23KB of byte-identical strategy content past the point where a
provider prompt cache can share a prefix.

Introduce a canonical XML tree primitive and a v2 bundle schema that nests
one element per spec slot. Because system_prompt is now the shared cache
prefix, it may hold only cross-task-constant content, so the recipe
identity, runtime planning facts, runtime tool environment, client system
prompt and form override all move into context, and user_prompt moves last.

Measured on the real strategy assets: 28,325 -> 26,010 bytes, 4 -> 16 CDATA
leaves, 7 -> 0 heading inversions, and the first byte that differs between
two tasks moves from offset 1,268 to 24,709 (past the end of system_prompt).

Persisted rows keep working: final_text_schema is dispatched per row, so a
v1 bundle still parses at its own version while new tasks mint v2.

* feat(daemon): let the main Agent choose the task route

Product spec 3.1 makes the main Agent the party that decides Direct Edit vs
Full Plan, and 6.1 puts that decision inside MVP scope. The daemon instead
locked the route before the Bundle was ever sent: routes/runs.ts passed
preference 'full_plan' with all five Direct Edit eligibility facts hardcoded
false, and decideStrategyRequestRoute short-circuits on that preference, so
Direct Edit was structurally unreachable. A local edit on an existing
artifact therefore paid a full planning round — measured at 2 Request Turns
and 61s for a colour change that the Agent itself correctly scoped to one
property.

Four of the five eligibility facts require reading the request, which the
daemon cannot do before dispatch. So the route now stays unlocked through
the request turn — a state the task store and the projection contract both
already model — and the daemon adopts the route the Agent declares in its
Runtime State. Every later turn keeps the existing lock: a route may never
change once set.

The Full-Plan-only guards for clarification inference, serialization repair,
and the repair anchor now also accept the unrouted first turn, since each of
those paths is itself a Full Plan declaration and locks the route as it
transitions. blockTask settles an unroutable turn on the spec's documented
fallback route rather than refusing to record it.

No contract field, DB column, or prompt byte changes: the recipe already
tells the Agent to choose the allowed route on the request stage, and
Runtime State has always carried .

* fix(strategy): tell the Agent it owns the request-stage route choice

Unlocking the route in the daemon was necessary but not sufficient. The
recipe still read "use the supplied task facts to choose the allowed route"
and immediately followed with "For a Full Plan route, the request and
clarification stages are planning-only", so the Agent kept assuming a Full
Plan had already been chosen for it. Verified through the real UI: an
unambiguous local edit still came back as full_plan with the Agent stating
'this turn will not modify files'.

The request-stage section now says the choice is the Agent's, that Open
Design leaves the route unset until the first Runtime State declares it,
and that the active orchestration Skill's eligibility conditions decide
between the two. It states the mechanism and defers the criteria to the
Skill rather than restating them.

Verified end-to-end in Chrome against a real codex run: a local colour edit
on an existing artifact now routes direct_edit / simple and completes in
ONE Request Turn (50s), matching spec 3.4. Ambiguous requests still route
full_plan, and a request needing clarification still routes full_plan
because Direct Edit is request-only.

* fix(observability): restore failed-Run process evidence in the task hierarchy

PR #7016 replaced the per-Run Langfuse trace with a task hierarchy
(`strategy-task:<taskExecutionId>` + `strategy-stage:*` child spans) and
durably suppresses the single-Run trace. Failure classification survived
the move, but the terminal process evidence the single-Run trace carried
— stderr/stdout tails, host close diagnostics, exit code and signal —
did not reach Langfuse at all, so a failed Run lost the only facts that
explain *why* it failed.

Carry those facts through the existing safe-projection boundary:

- `SafeRunQualityV1` gains an optional `process` section
  (`exitCode`, `signal`, `stderr`, `stdout`, `diagnostics`), with
  `SafeObservationStreamTailV1` and a bounded `SafeRunDiagnosticsV1`
  record that admits only booleans and identifier-shaped values.
- `buildSafeRunQualityProjectionV1` builds it. Stream tails reuse the
  producer's existing 20-line / 4 KiB cap plus `redactSecrets`, then add
  the wider Prompt-stack local-path masking before `safeQualityText`.
  stderr follows the run error-message rule; stdout is the agent's own
  output and needs content consent, reporting its line count with an
  explicit limitation when withheld.
- `buildSafeRunQualityProjectionFromDaemon` collects the same summaries
  the single-Run bridge collected, and the aggregation projects them
  onto the `strategy-stage:*` span metadata for both the legacy and OTLP
  exporters.

The smoke test now asserts the task-hierarchy shape (trace id, child
span id/name, failure metadata keyed by runId) and re-asserts the
diagnostics coverage there, including that a local path in stderr is
masked before transport. Each case gets its own daemon and `OD_DATA_DIR`
via `vi.resetModules()`: the OD Next rollout stop latch lives in SQLite
and fires per daemon instance, so a shared data root silently pushed
later cases onto the compatibility single-Run reporter. The one case
that legitimately stays on that reporter (deepseek, outside the OD Next
runtime path) now asserts the same coverage on the single-Run trace.

* test(daemon): make the run-failure smoke deterministic under load

The failed-run smoke could report `expected 'process_exit' to be 'auth'`
under CPU pressure. Two independent sources, both closed here.

1. The fake `claude` was not a valid OD Next Claude runtime. `detectAgents`
   parses `claude -p --help` for every flag in the runtime def's
   `capabilityFlags`; once a physical Run is mapped to an OD Next strategy
   task, `buildArgs` sets `observeNativeChildBehavior` and THROWS when the
   probe never saw `--forward-subagent-text`. The fake advertised neither
   that flag nor `--agents`, so whenever the task mapping was already
   visible to `startChatRun` the Run died on that host-side TypeError
   before the agent was ever spawned — reported as `AGENT_EXECUTION_FAILED`
   / `process_exit` instead of the auth, rate-limit or timeout failure the
   case scripted. Captured in the wild on the `hang_timeout` case
   ("Claude native Child behavior observation requires advertised
   --forward-subagent-text support.", 9 events, no spawn) and reproduced
   deterministically against `buildArgs`: old help text throws, derived
   help text builds args containing the flag.

   The fake's help text is now derived from `claudeAgentDef.capabilityFlags`,
   so it cannot drift from what the daemon probes for and the Run always
   reaches the scripted failure regardless of mapping order.

   This gate pre-existed the task-hierarchy change — the same fake and the
   same daemon check are on `main`. But that commit gave every sub-case its
   own daemon and data root, which keeps the OD Next rollout active for all
   five instead of only the first, so exposure went from one Run per file
   run to five.

2. The events log was sampled at an arbitrary moment. `GET /api/runs/:id`
   publishes terminal status before the buffered `events.jsonl` write has
   necessarily reached disk, so re-deriving a classification from that file
   could see a log whose error record was still in flight.
   `readCompletedRunEvents` waits for the log's own completion marker — the
   `end` record the daemon writes last, before closing the stream — rather
   than for a fixed delay or for the assertion to pass.

Each case now also asserts the daemon's own settled verdict
(`run.failureCategory` / `failureDetail` from the terminal status), which is
what the chat UI, the persisted message and telemetry actually consume. The
one case rejected before the runtime finish path declares
`daemonPublishesVerdict: false` rather than tolerating a null.

Verified under load average ~45-50 on a 10-core box (22 spinners plus a
concurrent full daemon suite): case 1 alone 6/6, file + tests/observability
6/6, file + observability + telemetry-message-finalization +
prompt-telemetry 5/5, every case in isolation 4/4.

* refactor(strategy): reshape the OD Next Prompt Bundle to the PRD layout

The emitted v2 bundle wrapped the cache-stable head in a `system_prompt`
element the PRD never asked for, and named three blocks differently from
the spec. Adopt the PRD-literal naming with the wrapper removed:

  open_design_prompt_bundle
    open_design_core_system_prompt   (was system_prompt/core_system_prompt)
      execution_boundary / native_execution
      discovery_and_planning_surface / core_strategy
      output_contract / echo_guard   (moved in: both are output constraints)
    session_skills
    active_stages
    task_metadata                    (was task_config)
    context
    user_first_prompt                (was user_prompt)

Block order stays load-bearing: the first three blocks are byte-identical
across tasks of the same strategy version, task type, and execution
profile, so the shared provider-cache prefix now runs through the end of
`active_stages` instead of `</system_prompt>`.

Three noise cleanups land with the reshape:

- `frozen_skill_package` and `request_input_facts` are omitted when they
  carry no substantive content, generalizing the rule the PRD already
  mandates for `attachments`. An empty Skill roster and an all-zero
  workspace/comment/MCP state cost ~540 bytes and told the model nothing.
- `recipe_identity` drops `strategy_package_hash`, `task_skill_digest`,
  and `stable_prompt_identity`. They are audit-only; observability and the
  persisted strategy task record keep them.
- Prompt text naming the old tags is updated: the echo guard constant, the
  form-override strings in both call sites, and the map notes.

Schema id `open-design.od-next-prompt-bundle/v2` is unchanged — it has
never shipped — and the v1 parser stays intact for persisted rows.

* test(daemon): make ingestion waits report what actually arrived

A bare "timed out waiting for the task observation owning run <id>" does not
say whether the daemon delivered nothing, delivered the compatibility
single-Run trace instead, or delivered the span under a different Run — so a
CI-only occurrence costs a reproduction cycle to classify. Both ingestion
waits now append the received trace/span/generation ids, names and runIds to
the timeout message.

Diagnostics only; no assertion, budget or behaviour change.

* fix(daemon): gate OD Next admission on what the installed CLI advertises

Making OD Next the default design strategy turned every prototype/ppt/
marketing/hyperframes Run with `claude` into an OD Next Run, and every
admitted Claude strategy Run asks the CLI for native Child behaviour
observation. `claude.buildArgs` refuses that unless the installed build
advertises `--forward-subagent-text`, and the refusal is a thrown
TypeError inside the spawn path, so the Run dies as
AGENT_EXECUTION_FAILED before the agent ever starts.

Admission never established that fact. `runtimeCapabilityVerified` was
derived from a bundled capability fixture keyed by runtime path + agent
id + adapter version, plus an `ensureDetectedRuntimeVersions` probe that
reads only `--version`. The fixture proves what the claude-code runtime
*path* can do; it says nothing about the build on the user's disk. So the
daemon admitted a task it could not launch.

The symptom hid on developer machines. A real Claude Code install had
already populated the process-wide `agentCapabilities` map through full
detection, and that stale entry answered "advertised" for a fake CLI that
advertised nothing — which is why four pre-existing daemon suites and one
e2e suite passed locally and failed on CI hosts with no Claude Code
installed. Ambient developer state was deciding the verdict.

Establish the fact at admission instead. `ensureDetectedRuntimeCapabilities`
runs the same bounded `-p --help` read full detection already uses, keyed
by the resolved launch path so a configured CLAUDE_BIN cannot be answered
from another binary's cached probe. An installed build that does not
advertise what OD Next will demand loses admission and takes the ordinary
route, reported as `od_next_rollout_capability_advertised_capability_missing`
with the missing keys on the `[od-next-rollout]` line.

`customAgents` is required alongside `forwardSubagentText` because a simple
task may escalate to complex after planning, and discovering that gap at
the production stage would strand the task mid-flight.

Red spec: apps/daemon/tests/od-next-advertised-capability-admission.test.ts

* fix(daemon): let a re-entered turn resume its own session

latestCompletedAssistantMessageId excludes the caller's in-flight assistant
placeholder so it cannot count as conversation advancement. Its
resumableMessageId parameter exists to admit the stored session cursor back
through that filter — but the exclusion was evaluated first, so when the
placeholder IS the cursor the row was already gone and the admission never
applied.

That collision happens when a daemon-internal restart re-enters
startChatRun with the same assistant message after that message was already
persisted as the session cursor: post-tool recovery writes the cursor, and
nativeSessionContinuePending is consumed once, so a later safe-retry restart
arrives without it and falls through to the ordinary guard. The live session
was then abandoned and the whole transcript re-seeded. For an OD Next
continuation it is worse than a slow turn — the non-request stage refuses to
cold re-seed and blocks the task instead.

The exclusion now yields to the admission for exactly that row. Advancement
detection is untouched: any later succeeded message still wins on
position DESC and still fails the cursor comparison, which a new test pins
so the fix cannot later be simplified into an unconditional cursor match.

* fix(web): hide internal OD Next rollout controls

* fix(daemon): stop OD Next from discarding delivered work

Seven defects found by running the four OD Next runtimes end to end. Each one
silently threw away a turn that had actually done its job.

- OpenCode wrote deliverables outside the project. It resolves its workspace by
  walking up to the nearest enclosing git root rather than using its process
  cwd, so a managed project directory — never a repository, and in a
  development install nested under the checkout — made it adopt the whole
  repository. The project stayed empty, artifact detection saw nothing, and the
  Run reported `no_artifact`. The BYOK definition already carried this
  workaround; the native one was missed. Sent unconditionally like BYOK does:
  gating it would turn a missing flag back into silent data loss, where an
  OpenCode build without `--dir` should fail loudly at spawn.

- The `--help` capability probe read only stdout. OpenCode writes its entire
  help to stderr, so every one of its advertised flags resolved to `false` and
  the capability mechanism was inert for that runtime — `--dir` above, and
  `--dangerously-skip-permissions` on builds that support it.

- Optional argv flags were dropped on a cold daemon. `agentCapabilities` was
  only warmed by full detection, so a turn that spawned before Settings or
  `/api/agents` built argv from an empty map. Resolve the advertised flags on
  the run path first.

- One ordinary simple Run could disable OD Next daemon-wide. A child-evidence
  coverage gap latched the rollout stop regardless of execution mode, but only
  the complex production gate consumes per-child coverage; a simple task has no
  Child agents, so `unavailable` there is accurate missingness. Recovery needed
  an operator `od strategy rollout reset`.

- A Direct Edit that delivered but never declared it was refused. The agent
  writes the canonical deliverable, `validateRunDeliverable` resolves it, and
  then the turn carries no machine block at all. No repair could rescue it:
  serialization repair needs a recovered Plan Contract to anchor on. Infer the
  completion from delivery Open Design verified itself, fail-closed on every
  weaker signal.

- A production block named nothing. Every other blocking path persists its
  reason codes; this one did not, so the most common production block reached
  the client anonymous.

- The web client remapped a blocked verdict onto a Run that had succeeded and
  delivered, hiding the artifact behind a generic failure card.

Also: tell the request stage the canonical-deliverable rule that judges a
Direct Edit completion. It shipped only in the production continuation, so
Direct Edit agents were graded on a contract they were never given.

Blocked diagnostics now report which machine blocks the turn actually emitted,
because "malformed block" and "no block at all" have different remedies and the
reason code alone cannot tell them apart.

* feat(daemon,contracts): expose which turn a strategy Run belongs to

A Full Plan task runs as several physical Runs (request -> production) that
the user asked for once, and the daemon-issued continuation carries no user
prompt of its own. Clients had no way to tell that those Runs are one
conversation turn, so the continuation surfaced as an answer nobody asked
for.

The daemon stays the single writer of one message per Run. It now also
reports the logical-task position alongside each message
(strategyTaskExecutionId + strategyTaskRunIndex, resolved in bulk from the
Run mapping) and emits a strategy_task_continuation diagnostic before the
source stream closes, so a live client learns the turn is still open. The
SSE diagnostic channel is contract-typed for the first time — the daemon has
always emitted it, ChatSseEvent just never declared it.

The read-side projection deliberately bypasses the record parser: it is for
display, so it must stay cheap across a conversation and must never let one
unverifiable task record fail the whole message list. Existing conversations
resolve their history without migration.

* fix(web): render a Full Plan task as one conversation turn

Each physical Run of a strategy task arrived as its own assistant message,
so a Full Plan answered one question with two turns: the planning Run
finished mid-answer — author line, "completed", copy/branch/rate actions —
and the production Run then opened a second turn that no user prompt
preceded.

The fold happens purely in the view. Messages sharing a task execution id
are folded into the turn's first message in Run order, appending content,
events and produced files, so nothing is dropped and nothing is repeated.
The turn reports the latest Run's status: an intermediate Run finishing is
an internal step, not the turn ending. Direct Edit (a single Run) and
ordinary runs are returned untouched.

Folding in the view rather than by re-pointing a message at the successor
Run is deliberate — the daemon owns message writes, and a client that also
re-points would have the continuation's output persisted twice.

* fix(daemon): let every runtime finish an OD Next complex task

Complex production was reachable for Codex, native OpenCode and AMR but
finishable only by Claude, and the refusal arrived after a full production Run
had already been spent — then latched OD Next off for the whole daemon.

- Scope Build Package ownership to the runtimes that can answer it. Completion
  demanded `attributes.buildPackageId` on every Child, but ownership rides on
  Claude's `--agents` / `subagent_type` transport and only that adapter stamps
  it. Assertions a runtime cannot answer are now skipped for it while every
  lifecycle, graph, parent and ordering assertion still applies to all four.
  This also makes those lifecycle checks reachable at last: a failed package
  check used to `continue` past the started/terminal verdict for that Child.

- Publish child-evidence coverage for ACP. The AMR branch emitted none, so
  every AMR task aggregated as `child_lifecycle_unavailable_not_zero` — unable
  to distinguish "no Child agents" from "nobody was observing". The consumer
  already accumulated bindings, negotiation state and rejection reasons; it now
  summarizes them, and reports `unavailable` rather than `complete` when the
  capability was never negotiated. The branch had no child-close handler at all.

- Promote native OpenCode child evidence to L2. The live root stream filters
  child sessions out, so it can only yield a terminal-only candidate that fails
  the evidence graph on `child_started_missing` and refuses every complex task.
  `opencode export --sanitize` is the one surface pairing a child's parentID
  with its transcript, and it can only be read once the child has exited. Half
  a lifecycle is worse than none — it adds an id that fails the graph while
  displacing the L1 candidate carrying the bounded Prompt — so a partial or
  unavailable export leaves that Child wholly on L1.

- Recover an undeclared production completion. A production turn is only ever
  entered from a locked Full Plan and its schema admits no non-terminal
  outcome, so a turn that ran the frozen plan, delivered a canonical entry Open
  Design resolved itself, and then answered in prose has exactly one thing it
  could have declared. Refusing it discarded a finished multi-page deliverable
  already sitting in the project.

Two diagnosis surfaces that hid the above:

- A schema failure reported only Zod's first message, so an absent required
  field read as the bare word "Required" with no way to tell which field the
  agent omitted. Carry the path.

- Repair-anchor recovery only matched a block that was nothing but a fence, so
  an agent narrating either side of its JSON produced no anchor and the one
  allowed serialization repair could not engage. Recover the first balanced
  object too; it still has to satisfy the full schema and is still only ever an
  anchor, so a wrong extraction yields nothing.

* fix(daemon,contracts): judge Build Package ownership by the evidence

Complex completion asked whether the selected agent was Claude, then skipped or
demanded `attributes.buildPackageId` on that answer. Ownership is a best-effort
attribute riding on Claude's `--agents` / structured `subagent_type` transport,
so an agent allowlist gets all three of its edge cases wrong: a runtime that
starts stamping ownership stays unverified and its real evidence is discarded, a
Claude turn whose model slipped is refused outright and latches OD Next off for
the whole daemon, and the list needs maintaining for every future runtime.

Ask the evidence instead. Ownership is asserted when every Child observation
names a package and skipped when none can, so verification follows capability
without anyone maintaining a list. Every lifecycle, graph, parent and ordering
assertion applies either way.

The binding factory keeps its own `claude` check: constructing `--agents`
handles is transport-specific and genuinely belongs to that runtime, which is a
different question from whether recorded evidence can be verified.

Also tell the Agent whether the runtime has the verified structured native Child
lifecycle that complex mode requires. The core strategy makes it a precondition
for locking complex, and the capability snapshot already answers it, but only
its hash reached the prompt — leaving the Agent to judge a capability it cannot
observe.

* fix(contracts): teach the plan contract the shapes only a complex plan emits

The canonical Plan Contract example is a simple plan, and the schema rejects a
simple plan that declares Build Packages, so that example necessarily carries
`buildPackages: []`. The prose beside it spells out the element shape of
`buildRequirements` and `readinessArtifacts` but never of `buildPackages`,
leaving the one array unique to complex mode with neither a template nor a
description. A model asked for complex had to invent seven `.strict()` field
names: codex and opencode independently guessed `dependencies` plus a stray
`boundary`, and both terminated on
`od_next_protocol_plan_contract_invalid_schema`.

The same single-item example hides a second rule. It makes
`canonicalDeliverable.id` equal the sole `requiredDeliverables` id, so the
membership invariant reads as a coincidence rather than a constraint. A complex
plan declares one deliverable per page and picks one as canonical, at which
point codex emitted a canonical id that appeared nowhere in the list.

Describe both in the output contract, next to the element shapes already
documented there. With this, codex and opencode both reach
`executionMode: complex`, and opencode carries a complex plan through to
`outcome: completed` with three native `task` children and
`child_evidence_coverage_v1` reporting complete coverage of all three.

* fix(daemon): name the codex rollout root so a complex Run can see its Children

`collectCodexChildEvidence` reads native Child lifecycle out of
`<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*.jsonl`, and deliberately refuses a
homedir fallback so it can never attribute one install's sessions to another.
That leaves the caller owing it an explicit root — and no caller ever supplied
one. `spawnEnvForAgent`'s codex branch was a pure pass-through, so the
collector's `CODEX_HOME` guard was false on every default install and the
collector has been unreachable since it landed.

The visible symptom was a complex codex Run that locked `executionMode:
complex`, spawned three native sub-agents, delivered four valid files, and was
then refused certification for child evidence the daemon had never gone
looking for. The rollouts were on disk the whole time, with each child
declaring the parent thread id.

Backfill the root before launch, mirroring the AMR `HOME` and
`OPENCODE_TEST_HOME` backfills next to it. The collector's no-fallback
invariant is preserved: the caller still names the exact root it hands the
runtime.

* fix(daemon): report unobserved Children as missing evidence, not malformed

`resolveDaemonOwnedOdNextComplexRuntimeEvidence` always brackets the Child list
with a running/completed root pair, so the empty-observation branch in the
complex gate is unreachable in production: a Run whose Children were never
observed still arrives with two perfectly valid observations and falls through
to the graph check, which reports `od_next_complex_child_evidence_invalid`.

That sends whoever debugs it hunting a corrupt payload. In the case that
prompted this, nothing was malformed and nothing was observed — the collector
had never run at all. Name the two states apart so the reason code says which
one happened.

* fix(daemon): stop one corrupt task record from voiding a whole boot's terminals

Reshaping the OD Next Prompt Bundle changed the v2 child tag set but kept the
schema id `open-design.od-next-prompt-bundle/v2`, on the reasoning that v2 had
never shipped. That holds for releases and not for a developer's own daemon:
the previous v2 composer was HEAD long enough to persist rows that still claim
today's label over a layout its parser cannot read. Those rows raise a raw
`TypeError` out of `parseOdNextPromptBundleV2`.

Startup reconciliation called `reconcileStrategyTaskRunTerminal` unguarded, so
that TypeError escaped `reconcileDurableRunTerminals` entirely. One unreadable
record therefore skipped the task-fact seeding, the whole PostHog `run_finished`
and Langfuse replay loop, and the task-observation crash-window sweep — for
every other Run on that boot, not just its own. Twelve consecutive boots of a
local daemon lost their terminal telemetry this way, and it only stopped when
the data directory was wiped.

Absorb the failure per Run, the way `strategyTaskTurnsForRunIds` already does
for the message list: a task row nobody can read is that Run's problem alone,
and startup still owes every sibling its terminal obligation.

* test(daemon): expect the pinned workspace in the OpenCode connection argv

Pinning OpenCode's workspace with an unconditional `--dir` also reaches the
connection-test probe, which spawns in a per-probe temp directory. That is the
intended reach — a probe must not adopt the repository root as its worktree
either — but the argv assertion was still written against the pre-`--dir` shape
and had been failing since.

Assert around the minted path instead of removing the check: the fixed prefix,
the flag, an absolute value, and the remaining flags in order. The 1.3
compatibility this test guards is a property of the argument order, so the
order stays asserted byte-for-byte on both sides of the temp path.

* fix(daemon): let an agent probe end even when the CLI swallows SIGTERM

`execFile`'s `timeout` is not a settle guarantee. On expiry it only *sends*
`killSignal`, and the promise settles on `'close'` — which needs the child to
actually exit. An agent CLI that traps SIGTERM and keeps a handle open leaves
the probe pending forever, and no caller's `catch` ever runs, so the
degrade-on-probe-failure path every caller already has is unreachable.

That became a hang on the Run path once `startChatRun` began awaiting
`ensureDetectedRuntimeCapabilities`, so the argv it builds matches a daemon
that has served `GET /api/agents`. The probe's cache key includes the resolved
launch path, so a fresh binary always misses and always probes. Against a
SIGTERM-trapping CLI the Run then parked in `queued` roughly 490 lines short of
`run.status = 'running'`: never spawned, so the inactivity watchdog never
armed, so it never reached a terminal status either.

Kill probes outright instead. They are read-only metadata calls with nothing
to flush, and every caller already treats a rejection as "probe failed, use the
safe baseline". `killSignal` is listed before the option spread so a caller
that needs a gentler signal can still ask for one.

* fix(daemon): stop discarding a codex Child that its parent re-invoked

A Codex sub-agent does not run once. The parent re-invokes it, and every
invocation opens another turn in the Child's own rollout, so `started` followed
by N-1 `interacted` is the ordinary shape of one delegated Build Package. The
collector treated a Child with more than one own turn as ambiguous and dropped
it, which meant a complex Run delegating three packages had all three Children
discarded and reported `knownChildCount: 0` with
`availability: partial` — while three complete Child lifecycles sat on disk,
each declaring the parent thread id.

The guard was defending something real: terminals are read from the parent's
activity list, so handing every turn the whole session's activities would let
one parent record terminate them all and stamp them with a single `endedAtMs`.
Slice the activities by the turn they occurred in instead — the parent's
records align with the child's turn starts within tens of milliseconds — so
each turn reads its own terminal, and a turn the parent never closed falls back
to the terminal its own rollout recorded.

Known gap, left for a follow-up: `knownChildCount` is keyed per (session, turn),
so it now reports one entry per invocation rather than per sub-agent. It feeds
the coverage diagnostic only — the complex gate counts observations — but it
makes codex's figure incomparable with the per-child count OpenCode reports.

* feat(strategy): port spec normative content into OD Next assets (#7141)

* feat(daemon): admit Vela to the OD Next capability registry

Vela drives the same native OpenCode runtime this registry already admits under
`native-opencode`. Its Child mechanism is that runtime's, reached over the ACP
extension instead of the CLI stream, and its fixture passes all seven evidence
paths. Holding the tuple out therefore never withheld an unproven capability —
it refused OD Next to an agent on the separate grounds that the build is not
yet immutable, which left every AMR run on the legacy plugin with
`od_next_rollout_x1_capability_fixture_unverified`.

Admit it. Re-pin `agentCliVersion` here once Vela publishes a build reporting a
stable producer version; that is the condition the old comment was really
waiting on, and it is a version pin, not an admission question.

Verified against a live daemon: AMR now resolves `od_next_rollout_eligible` /
`active`, and a simple full-plan task runs through OD Next to
`outcome: completed` with a valid deliverable. Complex still blocks for a
separate and honest reason — the installed Vela advertises no
`child_agent_lifecycle` producer, so no Child can be observed at all — which is
a Vela-side capability, not a gate.

* fix(daemon): stop one runtime's unverifiable Children from disabling OD Next

The rollout stop latch is `daemon_instance`-scoped and survives restart, so it
must be reserved for failures of the machine contract itself — those are true
whichever agent hit them. Unverifiable Children are not: they are a property of
one runtime. Vela ships no child-lifecycle producer today, so an AMR complex
Run cannot be certified at all, and every one of them took OD Next away from
Codex, Claude and OpenCode until an operator ran `od strategy rollout reset`.

The same latch also fired after a SUCCESSFUL run. `availability` demotes to
`partial` on any limitation, and a Codex collection always carries
`codex_inherited_turn_excluded` — children replay their parent's turns — so a
complex task that completed and delivered still disabled the strategy behind
itself. This was observed on a live daemon: the task reached
`outcome: completed`, and the rollout latched `complex_child_unverified`
seconds later.

Child evidence now raises no daemon-wide signal. That task is already
fail-closed with its reason codes persisted, which is the proportionate
response; route/mode drift and machine-contract leaks still latch.
`complex_child_unverified` stays in the reason-code union so daemons already
holding that latch, and the records explaining them, keep parsing.

* fix(daemon,contracts): count Child agents, not their parent's re-entries

`knownChildCount` had no stated unit, so each adapter picked one. OpenCode
counts distinct Child ids; Codex identifies an observation per (session, turn)
and the count was derived from observation ids at the call site, so a Child its
parent re-entered was counted once per invocation. A run with three sub-agents
reported four, and the two runtimes' figures were silently incomparable.

Say the unit in the contract — distinct Child agents, not invocations — and let
the Codex adapter own its own figure, the way the OpenCode adapter already
does. The per-turn observation identity is unchanged: it is what keeps each
invocation's lifecycle separate, which the complex gate still reads.

Verified on a live codex complex run: three native sub-agents now report
`knownChildCount: 3`.

* fix(daemon): stop inferring a complex completion nobody verified

The production-completion inference recovers a task the agent finished but
failed to declare, resting on Open Design having resolved the evidence itself.
For a simple plan that evidence IS the canonical deliverable, so the inference
is sound. A complex plan additionally owes verified native Child lifecycle —
the property that makes it complex — and no deliverable check substitutes for
it.

Accepting any locked execution mode therefore certified Children nobody
observed. Caught on a live AMR complex run: the installed Vela advertises no
child-lifecycle producer, the collector reported `knownChildCount: 0` with
`availability: unavailable`, the turn declared nothing, and the task landed
`outcome: completed` — walking straight past
`evaluateOdNextComplexChildEvidence`. The same run blocks correctly once the
inference is confined to simple.

* docs(daemon): record that Vela's Child producer is verified end to end

A local Vela built from the unmerged child-evidence branch negotiates
`vela.opencode.child_agent_lifecycle`, and an OD Next complex task on AMR now
reaches `outcome: completed` with `availability: complete`,
`source: vela_opencode_acp`, `knownChildCount: 3` and no limitations — three
native OpenCode sub-agents, one per Build Package, each observed through the
ACP extension. `verifiedRuntimeSupport` is the flag that makes exactly that
claim, so it is no longer false. `published` stays false: the producer is not
on Vela's main, so no user can install a build carrying it.

Also write down why this needs no version gate. Child evidence is reached by
capability negotiation, not by sniffing `agentCliVersion`: a Vela without the
producer never advertises it, so a complex task blocks on honestly missing
evidence, and one that has it works the moment it is installed. Admitting the
tuple therefore cannot make an older Vela claim Children it never reported,
and nothing has to be walked back when the producer ships.

* fix(strategy): default prototype demo content to real referents

Field test on beta.104 (claude, active, completed): the model read the
authentic-imagery clause, then legitimately routed around it by choosing
fictional demo books with CSS-drawn typographic covers — the clause is
conditional on content referencing a real entity, and nothing constrained
that upstream choice. The user-intended behavior (real books with real
fetched covers, for texture and credibility) never triggered.

Close the loophole with an affirmative default: prototype sample data
defaults to real, well-known referents with their real fetched images;
fictional stand-ins are a disclosed exception (user request or licensing),
and de-realizing content to avoid the fetch is named and forbidden. Core
baseline gets the matching one-liner so the default reads as baseline
discipline, not a profile-local whim.

Guard sweep zero hits; plugin-runtime 71/71, contracts 397/397.

* fix(od-next): stop offering to continue work the task already delivered

A finished Full Plan turn could read as unfinished, offer to continue itself,
and have that offer end in a second task that could only fail.

A Run carries two accounts of whether its declared work is done. OD Next's own
verdict reaches `completed` only after the coordinator confirmed BOTH a
succeeded process AND a canonical deliverable resolved on disk — the entry file
exists, is readable, was touched by this Run, and matches the project kind. The
other account is the agent's last TodoWrite snapshot, unverified narration that
agents routinely leave with a stale `pending` item after writing the artifact.

The snapshot was winning. QA saw a task settle `completed` with seven artifacts
delivered, stamped `endedWithUnfinishedWork`, offering "continue remaining
tasks"; taking that offer opened a second task with nothing left to write,
which validated as `no_artifact` and settled `blocked`. One project then held a
completed and a blocked task for the same work.

Let the verified verdict outrank the narration, through one predicate shared by
the daemon's terminal classifier and both surfaces that read the snapshot — the
pinned todo card's continue affordance and the per-message footer. A
mid-generation truncation stays an independent term, so a turn cut off by
max_tokens is unfinished whatever verdict was recorded.

The verdict also has to survive a reload. The messages table has no strategy
column, so it lives only in the task store, and the client's projection probe
skips a task that is already terminal — reopening the project brought the bogus
offer straight back. The conversation read path already joins the task store to
place each message in its logical turn; it now carries the verdict along the
same join.

Watching a real Full Plan run in Chrome surfaced a second defect on the way in.
The live stream re-points ONE assistant message at each successor Run, so that
message accumulates every stage's output, while the daemon persists one message
per Run. On the post-run refresh the two views meet and
`mergeServerMessageWithLocal`'s "a longer local body is fresher" rule (#6396)
reads the accumulation as freshness and keeps it, beside the successor's own
row: the production answer rendered twice, live, until a reload cleared it.
Scope that rule to messages with no later sibling in the same task — the final
Run has nothing to have absorbed, so the guard is untouched for it and for
every ordinary turn.

Deliberately unchanged: `artifactCount <= 0 -> no_artifact` still blocks. That
guard is what stops a turn claiming completion it never delivered, and the
recovery case QA hit disappears once the offer stops appearing on finished
work. A turn that legitimately has nothing left to write needs a "no change
required" outcome in the protocol, which is a product decision, not a defect
fix. Note also that a settled task cannot be resumed — the daemon rejects any
continuation of a terminal task with 409 — so a continue affordance has no
choice but to open a new task.

Also drops `onStrategyTaskContinuation`, a client callback with no consumer
whose contract described re-pointing the existing message at the successor Run
— the very shape that prints the answer twice. The daemon still emits the
diagnostic into the Run event log, where it documents the hand-off for anyone
reconstructing a multi-Run turn.

Red first: the end-to-end spec fails on the pre-fix daemon with
`endedWithUnfinishedWork` true, again with the reload verdict `undefined`, and
the merge spec with the production answer counted twice.

* fix(web): stop leaking the strategy package above a user prompt

An OD Next turn opened with two rows of chrome the user never asked for: a
"Design" session-mode chip and a "Using OD Next Strategy V2" disclosure that
named the internal strategy package and its version. Neither is context the
user attached to the message — the daemon binds the strategy package itself,
and the mode chip only restates the default that a strategy-owned design turn
already implies.

Key the suppression on `AppliedPluginSnapshot.strategy`, which the daemon
stamps only for an internal strategy apply (verified against real persisted
snapshots: OD Next rows carry `strategy_json`, ordinary plugin rows store
NULL). A user-picked plugin, skill, design system or workspace reference still
renders exactly as before, and Ask/Plan stay labelled everywhere because those
are per-turn behaviour choices the user made.

An OD Next design turn now opens with the prompt itself.

* fix(strategy): add action-level layout mechanics to the baseline

Field test on beta.105 (first AMR active sample, deepseek-v4-flash,
prototype completed): the artifact shipped two elementary geometry
defects — the city switcher and daily-judgment regions overlapped, and a
stat numeral's percent sign wrapped to a second line inside a fixed-size
ring. The existing rules could not have prevented either: they are
outcome-level ('overflow-free layout', 'no overflow or clipping risk'),
and under ship-on-write the model never sees the rendered outcome it is
asked to guarantee.

Replace the bet on outcomes with rules about the source itself — verifiable
while writing: flow-only layout for sibling content regions (no absolute
positioning / negative margins / transforms to stack them), fixed-size
boxes only for fixed-size content, clamp()-sized display numerals with the
unit pinned to the same line, block-level display declared before sizing
track-style elements, no hidden-overflow masking, no 1–2 character
orphans. Core gains the Layout mechanics baseline bullet; the prototype
quality floor names the two observed failure shapes.

Guard sweep zero hits; plugin-runtime 71/71, contracts 397/397.

* fix(strategy): add measure-then-size image geometry to the baseline

Follow-on field sample: a fetched, localized movie poster rendered
cropped inside a container whose fixed ratio the model invented — a
failure class created by the real-first imagery clause itself, since
fetched assets carry intrinsic dimensions the model never authored.

The fix is procedural, not aspirational: read the file's intrinsic
width/height during Build (input probing sits inside the ship-on-write
allowance), give the container the measured ratio (aspect-ratio or
natural flow), reserve cover-cropping for decorative backdrops, and
render content-bearing images — posters, covers, artwork, product
shots — at their full frame with no double-locked axes. Core gains the
Image geometry baseline bullet; the prototype quality floor names the
observed failure shape.

Guard sweep zero hits; plugin-runtime 71/71, contracts 397/397.

* fix(strategy): anchor overlays on the anchoring axes only

Address PerishCode's review: 'explicit inset on every side' would, taken
literally, set all four offsets on an auto-sized absolutely positioned
badge and stretch it to fill its containing block — the exact malformed
overlay geometry this baseline exists to prevent. Anchor with offsets
only on the anchoring axes (top plus right for a corner badge), size
constrained independently, and name the stretch trap explicitly.

* revert(strategy): split image geometry out of this bugfix

Per review: the measure-then-size image-geometry policy addresses a
separate field failure (localized poster cropped by an invented fixed
ratio) and belongs in its own focused PR with that reproduction. This
branch returns to its stated objective: region overlap, stat-unit
wrapping, and track sizing.

* fix(strategy): add measure-then-size image geometry to the baseline

Field reproduction (beta.105, od-next active, prototype route): the
real-first imagery clause now makes runs fetch and localize real assets
— and the first such sample shipped a localized movie poster rendered
cropped, because the model invented a fixed container ratio for an
image whose intrinsic dimensions it never authored, then let cover-crop
hide the mismatch. The page showed a partial poster frame: wrong ratio,
content cut off. This failure class is created by fetched assets
carrying dimensions the model has not seen, so it cannot be prevented
by the existing outcome-level rules.

The fix is procedural and write-time-verifiable: read the localized
file's intrinsic width/height during Build (input probing sits inside
the ship-on-write allowance), give the container the measured ratio
(aspect-ratio or natural width:100%/height:auto flow), reserve
cover-cropping for decorative backdrops, and render content-bearing
images — posters, covers, artwork, product shots — at their full frame
with no double-locked axes. Core gains the Image geometry baseline
bullet; the prototype quality floor names the observed failure shape.

Split out of #7169 per review scope discipline.

Guard sweep zero hits; plugin-runtime 71/71, contracts 397/397.

* fix(strategy): allow intentional fixed and sticky chrome

Address PerishCode's review: 'normal flow only' contradicted the
prototype profile's own environment rules, which expect fixed headers,
bottom navigation, and floating controls that reserve matching padding —
a model following the core rule literally could strip valid mobile
bottom navigation. Narrow the prohibition to its actual target
(accidental stacking of sibling content regions) and state the chrome
allowance with its padding obligation in both the core bullet and the
prototype quality-floor line.

* fix(web,daemon): keep internal turn chrome and control markers out of the chat

Two acceptance findings, both the same shape: daemon-internal plumbing reaching
the reader.

The session-mode chip flashed "Design" above a freshly sent prompt and dropped
it a beat later. The optimistic user message renders before POST /api/runs
answers, so the strategy binding the previous fix keyed on simply is not there
yet. Key the chip on the mode alone instead: it labels a turn the user steered
away from the default, so Ask and Plan stay labelled and Design — the mode every
design project already runs in — carries no chip. That is decided the moment the
message renders, with no snapshot to wait for.

`<od-title>…</od-title>` rendered as message body. The stripper was armed only
for Runs that requested a title, and the web only requests one on the first turn
of a conversation, so from turn two onward nothing consumed the marker while the
directive was still sitting in the agent's own resumed session history. Arm the
stripper on every Run and swap only the announce callback, then strip again in
the chat: the OD Next machine protocol blocks join the title marker there, and
messages already persisted with a leaked marker render clean, which no
daemon-side fix can reach. Renderable artifacts (question-form, od-card,
artifact) are deliberately excluded.

* fix(web,daemon,strategy): stop a stray question-form marker from stalling a turn

An OD Next turn that needed no clarification narrated its decision straight
into the marker — `<question-form> 无需提出——…`, an unclosed tag whose body is
prose. Four surfaces independently read "an open marker is present" as "a form
is being generated", at three different strictness levels, and none of them had
a state for markup that can never render:

- the web parser raised a loading skeleton and truncated every character after
  the marker, so the turn's real output was invisible;
- `hasPendingQuestionForm` held the turn in a clarification handshake;
- `resolveDesignDeliveryOutcome` returned `awaiting_input` before looking at
  what the run had delivered;
- `listProjectsAwaitingInput` matched the marker with a raw SQL `LIKE`, latching
  the project on `Needs input` until the user typed something.

The daemon's own strict detector meanwhile scored the stray marker as zero
forms, so the coordinator accepted the turn with an empty reason-code list. The
one component that could have reported the contract break was blind to it by
construction.

Give every one of those decisions the same precondition — an open marker only
means a form is in flight while the text after it could still parse as the form
body — and make the malformed case reportable instead of silent. The two new
coordinator codes are attribution, never a gate: every code `validateAcceptedTurn`
returns blocks the turn, so a stray marker on an otherwise valid planning turn
still reaches production.

The awaiting-input queries needed care. They take the newest form-bearing
message per partition and test only that one for an answer, so the strict check
has to run before the newest-per-partition pick. Post-filtering the winning rows
would let a stray marker win its partition, fail the check, and drop a project
that is genuinely waiting on a real unanswered form. Both directions of that
ordering are pinned by specs, and the scan streams without retaining message
content because it runs on every `GET /api/projects`.

The strategy prompt now also forbids restating the marker to announce that no
form is needed. Every clarification rule was phrased as "when to emit a form" /
"skip the form"; nothing forbade writing the bare marker as a label, which is
what produced the turn.

* fix(strategy): define the session-skill conflict order (#7188)

The Instruction order named the three session-skill layers but gave no
tie-break when they conflict inside their shared applicable scope, so a
user-selected skill and a bundled profile could pull the model in
opposite directions with no principled resolution. Make the order
explicit: user-selected skills first, then the general orchestration
Skill, then the task-type profile — and state that the tie-break never
unlocks what higher rules forbid (ship-on-write actions, the V2 machine
contract, the user's explicit requirements). Spec source of truth
updated with matching wording in both languages.

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>

* feat(od-next): restore anti-cliché visual rules across the strategy package

The od-next rewrite dropped the legacy anti-slop blacklist (core-slim /
discovery ❌ lists), leaving the package with no background-tone rules at
all — prototype.md even referenced 'the anti-cliché rules above' that did
not exist. Models revert to their warm-cream 'AI look' prior on every
task type.

- core-system-prompt.md: new cross-task 'Anti-cliché defaults' block in
  the Design baseline (warm beige/cream grounds, purple-gradient wash,
  stock UI display faces, left-border accent cards, icon-per-heading,
  gray hover, hand-drawn SVG people, invented metrics)
- prototype.md: resolve the dangling reference with a labeled rule
  cluster; add the designer/demo-controls ban
- ppt.md: new 'Avoid deck clichés' section (gradient-wash covers,
  icon-per-bullet, center-everything, corner blobs)
- marketing.md: cream/stock-gradient ground ban + never invent awards,
  badges, press quotes, or testimonials
- hyperframes.md: cream color-wash grade + particle/glow filler ban

Asset versions unchanged: package identity tracks content via sha256
digests; tests pin '2.0.0' literally.

* feat(web,daemon,contracts): carry an official example card into OD Next

Clicking an official example card under a task type pinned that example as an
executable plugin. The pin stamped explicit_user authority, which took the task
off the automatic route entirely — no strategy task, no plan, no clarification
round — while free text under the very same chip entered OD Next normally.

An example card is a more specific choice inside a task type, not a claim on
the strategy. So send its identity instead of a plugin pin: `exampleReference`,
which the daemon re-resolves through the same local catalogue lookup
`/apply-local` performs, reading the example's bytes from the resolved path and
never from the request body. Its SKILL.md and the side files its prose names
then travel where user-chosen material belongs — `session_skills/
user_selected_skills`, a bundle region that already existed in exactly this
shape and had no producer. No bundle format change, no schema bump.

The discriminator has to be the call site, not the record: the same
`InstalledPluginRecord` is simultaneously an example card, a chip's default
scenario plugin, and a Community entry, and nothing in its data tells the three
apart. `examplePick` is therefore set only by the example-rail handler, and a
genuine plugin pick from the Community page keeps its explicit authority. An
example card under a chip with no OD Next route — wireframe, mobile, image,
video — behaves exactly as before.

Drop the planning/Build-only guard from user-selected Skill capture. Measured
against real content it rejected most of the corpus it was applied to while
protecting almost nothing: it is 15 English regexes, so the same sentence
passes or fails on language alone — "After the build, run through the checklist
and fix any defects" is refused and its Chinese equivalent is admitted. It also
false-positives on prose that merely names the word, and it never inspected
side files at all, so a `references/checklist.md` always walked past it. 47 of
183 shipped examples tripped it, including all three default cards. Conflicting
instructions are resolved by the Instruction order the core system prompt
already defines; first-party strategy assets keep the guard, where the corpus
is controlled and the rule works.

Two latent bugs in that rail surfaced once it had a caller, both of the same
shape — one bad side file deleting an entire Skill. A prose link to a file that
was since flattened away (`example-hyperframes`) and a bundled screenshot past
the per-file byte cap (`example-open-design-landing`) each sank the whole
capture. Neither says anything about whether the Skill is safe to carry, so
both are skipped; a symlink, a symlinked parent, or a file that changes under
the read still fails closed. The byte cap keeps its purpose — oversized bytes
are never read — and `readBoundedNoFollow` still enforces it on what is read.

Answer the brief's template from the card's own input defaults. `od.useCase.
query` is a template that the ordinary route fills from the applied snapshot's
inputs; an example card deliberately creates no snapshot, so without this the
Agent received `{{artifactKind}}` and `{{audience}}` raw — a broken template
rather than an assignment. A field with no declared default keeps its token
instead of inventing an answer the card never gave.

Verified against the shipped corpus: 182 of 183 example folders capture and
serialize into a `user_selected_skills` region, the one exception shipping no
SKILL.md and belonging to no OD Next route. 102 cards across Prototype, Deck
and HyperFrames now enter the automatic route; the other 81 are untouched.

* fix(web,contracts): keep a nested Prototype scene on the OD Next route

Picking a second-level scene under Prototype — Mobile app or Wireframe — took
the task out of OD Next entirely, for free text and example cards alike, while
the first-level Prototype chip worked. Two independent layers excluded them and
cross-check each other fail-closed, so neither could be relaxed alone: the
composer swapped the route id to the scene's own catalog action id, which the
route table does not carry, and the metadata re-derivation separately rejected
`fidelity: 'wireframe'` and mobile platform targets.

Neither exclusion was protecting anything. The two scenes are the Prototype
route plus metadata, as their own chip definitions say — both bind
`example-web-prototype` with `kind: 'prototype'` and `automaticDefault: true`,
differing only by `platformTargets` or `fidelity`. And the Prototype task
profile already branches on exactly those two: it downgrades content and
interaction-state requirements when fidelity is wireframe or lo-fi, and it
carries mobile guidance down to touch reach and the 375/768/1024/1440
breakpoints. The route was being denied to the cases the profile was written
for.

A second-level scene refines WHAT to build, never WHETHER the parent task
type's route applies. Make that structural rather than enumerated: routing
resolves the first-level task type through `taskTypeChipIdForChipId` instead of
reading whichever catalog action the scene happens to bind, so a scene has no
route id of its own to route by and a scene added later cannot strand its
parent. Adding table rows would have fixed today's two and left every future
scene a silent-regression candidate.

On the contracts side `intent` is now the only field that can move a project
off a route — it is the only one naming a different pipeline. `fidelity` and
`platformTargets` stay in the signature to record that the decision has seen
them and chosen not to gate on them.

Deck and HyperFrames were never affected and now have witnesses that say so:
Deck's second-level rail is a card filter that never leaves the hero, and
HyperFrames has no rail at all. A catalog sweep pins the routed chip set to
exactly deck, hyperframes, mobile, prototype and wireframe, so Image, Video,
Audio, Document, Website clone, WebGL, Figma and the rest cannot join it
silently.

* refactor(web): make a nested Prototype scene a second-level type

#6914 moved Mobile app and Wireframe out of Home's first-level rail and into
Prototype's second-level scene rail — but only in the UI. Underneath, both
stayed full first-level chips with their own `apply-scenario` action, merely
hidden from the create rail, and the scene pointed back at them through
`actionChipId`. Its own comment recorded the compromise: keep their catalog
action ids so the nested scene preserves the metadata those actions stamp.

A thing that renders as a refinement of Prototype while still being a task type
in its own right is what dropped these two scenes out of OD Next: the composer
resolved the route from whichever action the scene bound, and Mobile and
Wireframe are not routes. `f360f46e0` stopped the routing site from asking.
This removes the ambiguity it was working around.

A scene now carries its own refinement — Mobile stamps the handheld platform
targets, Wireframe stamps the lo-fi fidelity — and binds its parent. The two
chips leave the catalog, because they were never task types. The refinement
type cannot restate its parent's `kind`, so a scene is structurally incapable
of becoming a type again.

Nothing the user sees changes: same eight scenes, same labels, same metadata
stamped byte for byte, same Prototype route, same example-card behaviour. The
integration assertion was tightened from `objectContaining` to an exact match
to say so, and the unit specs compare the serialized metadata rather than its
fields, since project metadata is persisted and compared as JSON.

Legacy ids still arrive from outside the catalog, so normalization moved to the
two boundaries where that actually happens — a persisted composer draft and a
queued one-shot chip intent — instead of living at the routing site. The queued
intent had no coverage and relied on `pickChip` normalizing before `findChip`;
it has a spec now. `taskTypeChipIdForChipId` is gone: with the scenes out of
the catalog, `active.chipId` is a task type by construction, and every producer
was traced to confirm none can yield a scene.

The placeholder carousel had the same latent shape — three scenario lines bound
a scene as if it were a chip — so they bind Prototype plus a scene id, and the
carousel narrows by scene slug. A one-click create from a scene's carousel card
now has a spec proving it keeps both the refinement and the route.

* fix(web): stop a restored composer pick from costing its task type

Dismissing the pill of an example card returned the Home composer to nothing —
no task type, no OD Next route, and the user's typed brief wiped with Send left
disabled — but only after the composer had been remounted. Opening a project,
visiting Settings, any EntryView swap: the composer comes back looking
pixel-identical, same type selected, same scene, same pill, same brief, and the
dismiss now destroys instead of stepping back.

The persisted chip draft records what was picked and never how. A restored
example card therefore comes back as an ordinary chip binding, while still
rendering its own pill and dismiss control — that check asks whether the record
differs from the chip's default plugin, which is true for every example card.
The dismiss handler keyed its step-back branch on the pick being explicit, so a
restored pick missed it and fell through to the reset that clears everything.

Persist how the pick was made and restore it, so a pick is the same object
after a remount as before one. Then key the step-back on the pick naming a task
type rather than on it being explicit: whatever a pick was, if it named a type
the user chose, dismissing it returns there. Only a pick that never named one
resets the composer, and the typed draft is left exactly as typed either way.
Drafts written before this carry no flags and restore exactly as they do today.

The same lost provenance had a second victim: an example card that was never
dismissed stopped sending its `exampleReference` after a remount, so the card's
own material silently vanished from the run while the route survived — on all
three OD Next routes.

The reproduction drives the real rails and the real bundled catalog through ten
first-level and second-level combinations, each on a fresh mount and across a
remount. The differentiator was never the type or the scene: every combination
loses its route once the pick's provenance has been through storage.

* feat(od-next): ship handheld device shells with the prototype task profile

Blind evals against the live slim-sp strategy showed OD Next losing every
phone-app prototype on one detail: the live route hands the agent a real
handset seed (design-templates/mobile-app), OD Next only described one in
prose and the agent drew a rounded card by hand.

- Declare task-profile `resources` in the bundled strategy contract; the
  prototype profile ships iPhone / Android / platform-neutral shells that
  enter the package identity with the profile (rule-card and shell edits
  move the hash alike) and are re-verified at consumption.
- Stage the shells into `<cwd>/.od-frames/` on every strategy run and
  add a "Handheld device shell" section to the prototype rule card with
  the brief→shell table and the shell invariants.
- Resolve the platform from project metadata and the user's own words
  (ios / android / mobile-neutral; responsive and desktop briefs veto the
  platform-less class), latch it per conversation, and quote the selected
  shell into the stable request context as `device-frame` +
  `device-frame-shell` facts.
- Observe at run finish whether the delivered entry kept the shell and
  expose it on run_finished (od_next_device_platform / _source /
  _shell_present) so the rollout can measure adoption.
- Shells collapse to a full-bleed phone page below 480px and scale on short
  viewports so the artifact still meets the 375px rule.

* docs(od-next): describe task-profile resources in the strategy package SKILL.md

* fix(od-next): stage device shells non-destructively and cover the collab exclusion

Review: `.od-frames/` is a name this PR introduces inside a user-owned
project directory, so wiping it on every run could delete pre-existing
content. Staging now keeps an ownership manifest
(`.od-frames/.od-next-device-frames.json`): only files it recorded earlier
are replaced or retired (and retired only while their bytes are still
ours); a pre-existing file under a managed name is left untouched and
reported as skipped; unrelated files are never read, written, or deleted.
Tests prove an unrelated file survives, a foreign `iphone.html` survives
repeated staging, and a user-edited staged file is not retired.

CI: `.od-frames` joins the expected member-mirror exclusion list in
`vela-cli-resource-adapter.test.ts`, which pins that list exactly.

* fix(od-next): prove device shell bytes are ours before replacing them

Review: listing a name in the ownership manifest was not enough to claim
it. After the first staging a user could edit `.od-frames/iphone.html`
and the next run's unconditional `writeFile` destroyed that edit, and a
pre-existing `.od-next-device-frames.json` under an unrelated schema was
read as an empty manifest and then overwritten.

Ownership is now provisional on both sides. A managed name is written or
retired only when the manifest claims it AND the bytes on disk still hash
to the recorded digest — the rule the retirement path already followed,
now applied to replacement too. A digest that moved makes the file the
user's: it is reported as skipped and drops out of the manifest, so later
stagings keep their hands off it. The control file gets the same
treatment: `readOwnership` classifies the name as absent / ours / foreign
instead of collapsing unreadable, malformed, and unrelated content into
an empty manifest, and a foreign control file makes the whole directory
off limits — nothing written, nothing deleted, every managed name
reported as skipped. In each case the quoted `device-frame-shell` fact
still carries the shell source, so the run itself is unaffected.

Two fixture cases, both red on the previous head: a shell edited after
staging survives repeated staging byte-for-byte, and a pre-existing
invalid manifest plus the user's own `iphone.html` survive untouched with
no shells created.

* fix(od-next): bound the device shell manifest to the names it can stage

Review: the manifest lives inside a directory the project controls, so
its key list was letting the project widen this feature's deletion set.
The parser accepted any slash-free basename with a SHA-256 digest, and
the retirement loop then deleted any accepted entry missing from the
current package while its bytes still matched. A project holding a
same-schema `.od-next-device-frames.json` that claims `leftover.html`
with that file's real digest therefore lost `leftover.html` on the next
strategy run — verified, not hypothetical: the new fixture fails on the
previous head with `ENOENT` on that file.

The bound is now this feature's own filenames rather than whatever the
manifest names. `MANAGED_SHELL_FILES` is derived from
`OD_NEXT_DEVICE_FRAME_FILES`, and a manifest is `ours` only when every
key is in that set and every value is a 64-hex digest — that is exactly
what this materializer writes, so anything else came from somewhere else
and the whole record is refused as foreign, leaving the directory alone.
Refusing beats salvaging the well-formed entries here: salvaging is what
would let a crafted entry keep nominating an unrelated file. The
retirement loop re-checks the allowlist as well, so the deletion set
stays bounded even if the parser is later loosened.

Manifests written by the previous head hold only shell names and valid
digests, so they keep reading as ours; no migration.

* fix(od-next): let an @-mentioned Skill ride the automatic route

Mentioning a Skill in the composer took the turn off OD Next entirely. The
project fell back to its task type's legacy plugin, so the chat header
announced "正在使用 Web Prototype" and the run reached the agent as ordinary
prose with no Prompt Bundle at all — no Runtime State, no Plan Contract, none
of the strategy the task type was supposed to own.

Three layers each dropped the Skill or the route, and every one of them had to
give for the turn to survive intact:

- The admission gate counted a named Skill as explicit user authority, next to
  a pinned plugin and a pinned snapshot. A Skill is not an executable surface
  and cannot run a strategy; it is material for one. It now stays out of that
  read, and a context plugin remains the only non-plugin signal that still
  claims the route.
- Home treated the task type and the Skill as mutually exclusive: picking
  either one wiped the other, and submit dropped whichever survived. That rule
  came from #2972, which asked for a defined answer when the prompt's intent
  and the picked card disagree. "The Skill wins, drop the card" is defined, but
  it routed a user who picked 幻灯片 into a prototype project, which is exactly
  the silent misrouting #2972 objected to. The task type now owns the route and
  the Skill is material carried inside it, so the conflict resolves without
  discarding either.
- The create handoff nulled `skillId` the moment a task profile was claimed,
  which would have swallowed the Skill one layer after Home stopped dropping it.

The catalogue capture entry point for exactly this already existed and had
never had a production caller — the gate above made it unreachable. It is now
wired through a single session capture that converges both channels a session
can select material through, because one task freezes ONE package: an
@-mentioned Skill and an official example card land in the same
`session_skills/user_selected_skills` slot, the mention first, which is the
authority order the strategy's own conflict rule already assumes.

Capture is fail-soft on every leg, matching the example-card rail: a Skill
deleted between picking and running, a catalogue that will not list, a
selection past the package bounds — each drops that material and runs the task
without it. The ordinary route drops an unresolvable Skill silently too, so
this is the same contract rather than a new leniency, and an unfreezable
selection can never cost the project the example card it was created from.

Skill-catalogue scoping moved to one helper shared with the system-prompt
composer. A Skill one surface can resolve is now a Skill the other can freeze;
resolving them from separately-derived partitions would drop a Workspace Skill
on whichever side guessed narrower.

Coverage: the three request shapes that carry a Skill (`ChatRequest.skillId`,
`skillIds`, and the Skill persisted on the project row by Home's create) each
have an HTTP witness asserting the Bundle text; all four routed task types are
covered for a mention arriving alongside their default card. Two server tests
that encoded the old "Skills route ordinary" contract, and the two Home specs
that encoded the mutual exclusion, were rewritten rather than deleted — each
now states the rule that replaced it.

* fix(od-next): accept a HyperFrames composition as its canonical deliverable

A HyperFrames run that wrote an editable root `index.html` was blocked with
`od_next_canonical_deliverable_invalid`, surfacing as "任务执行失败" even though
the artifact was sitting in the project and working. Every HyperFrames
production turn in the reported diagnostics bundle hit it.

`validateRunDeliverable` gated the entry's file kind on `metadata.kind`, and
`kind: 'video'` accepted only `video` files. But `kind` records which Home
surface a project was created from, not the shape of what it delivers. For
every other kind those coincide; HyperFrames breaks the identity — it is a
local HTML-to-video renderer, so the HTML composition is the authored artifact
and the MP4 is a render of it, produced on demand and not necessarily present.

OD Next already said so on the way in: `DAEMON_OWNED_OUTPUT_KINDS.hyperframes`
admits `html` and `source` as supported deliverable kinds at plan time, so the
same daemon approved a plan the completion check then rejected.

Resolve the accepted kinds from the project's metadata instead of its bare
`kind`, via one named invariant used by both entry inference and the kind gate.
Generative video (fal / Veo / Sora / Volcengine) keeps the strict video-only
contract: there the provider returns a video file, and an HTML preview page in
its place is a genuine delivery failure.

The `hyperframes-html` discriminator moves next to `ProjectMetadata` so
delivery validation and analytics `project_kind` read one constant.

Red spec: `apps/daemon/tests/run-deliverable-validation.test.ts` reproduced the
production `type_mismatch` before the fix, and covers both guards after it.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Cheems <cheems1229282@proton.me>
Co-authored-by: 黄桃 <huangtao@huangtaodeMacBook-Pro.local>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: hihijack111 <216108319+hihijack111@users.noreply.github.com>
2026-08-24 08:53:36 +00:00
e34d823160 fix(web): gate PPTX export on a daemon-advertised renderer capability (#7224)
* fix(web): gate PPTX export on a daemon-advertised renderer capability

The export entry was offered whenever the artifact looked like a deck, so
in a deployment without an off-screen renderer (headless / container) the
button was always shown and always failed: `POST /export/pptx` answers 501
whenever `desktopSlideRenderer` is absent, and the check sits *before* the
editable/screenshot branch, so the mode picker in the modal was a choice
that could not affect the outcome either.

Ask the daemon instead of guessing. `/api/version` now carries an optional
`capabilities.slideRenderer`, computed from the very binding the export
routes guard on, so the advertisement cannot drift from the 501 they
return. It rides on the version payload because that is already the
"what runtime am I talking to" channel (packaged/channel/platform/arch),
already fetched once at boot, and — unlike `/api/app-config` — never
persisted: the same data dir can be opened by a desktop daemon today and a
headless one tomorrow, so a stored answer would be a stale claim.

The flag deliberately cannot be derived from `packaged`: a packaged binary
started as a plain daemon has no renderer.

Mixed versions are the delicate part. The field is optional and the client
treats absent as *unknown*, not false, so a newer web bundle keeps showing
PPTX against an older daemon that supports it but does not advertise it
yet. Only an explicit `false` hides the entry. A present-but-malformed
block is dropped rather than trusted, and an unresolved answer stays
retryable so one early fetch against a not-yet-ready daemon cannot pin
"unknown" for the session.

Client-side `isOpenDesignHostAvailable()` was not used for this even though
it is the existing desktop-only gate: it reports whether the web UI runs
inside the Electron shell, which is wrong in both directions here — a
browser pointed at a desktop-hosted daemon does have a renderer, and a
packaged daemon may not.

Also splits the two states that `unavailable: true` collapsed. A dead
daemon and a runtime with no renderer both produced "PPTX export is not
available here", which sends someone whose daemon is down to look at the
wrong problem. Callers keep the existing `'unavailable' in res` fallback
behaviour; only the message differs.

* test(web): pin the two unavailable reasons in the export specs

The three specs that asserted the old flat `{ ok: false, unavailable: true }`
now pin which reason they exercise: the two 501 cases as `no-renderer` and
the transport failure as `unreachable`. Without this the split is only
enforced by types, so a later edit could collapse both back onto one value
and every assertion would still pass.

* fix(web): let a late capability answer reach an already-mounted viewer

Review catch (thanks @nettee): the first shape probed once on mount and
ignored an unresolved result, so a probe that raced daemon startup or
blipped left `available` at null for the life of that mount. The gate then
stayed permissive and the entry could still be clicked into the very 501
this change exists to prevent. Worse, the module raced the app's own boot
fetch with a second request, so a failed probe here could keep the entry
showing even when the boot response had already reported `false`.

Three changes, all aimed at that gap:

- The boot fetch now feeds the cache. `fetchAppVersionInfo()` hands its
  parsed payload to `recordAppVersionInfo()`, so the common path costs no
  extra request and the two paths cannot disagree. The dependency runs
  registry -> runtime-capabilities only, so there is no cycle; this module
  no longer imports the registry and does its own minimal probe when it
  still has to.
- Consumers subscribe. An answer arriving from boot, from a retry, or from
  another mounted consumer now reaches every mounted viewer instead of
  leaving each one pinned on its own failed probe.
- Unresolved probes retry with a bounded backoff (500ms / 1.5s / 4s) and
  then stop. An unreachable daemon is its own visible problem and should
  not be polled forever; a startup race is exactly what needs one more look.

Covered at the level the review asked for: a FileViewer spec where the
first probe cannot resolve, the entry is still offered (unknown means
assume available), and a later explicit `false` removes it. Verified red by
restoring the old single-shot behaviour — the late answer reaches nobody
and the entry stays.

* fix(web): recognise proxy-answered daemon outages as unreachable

Review catch (thanks @nettee): the daemon-down diagnostic did not actually
fire on the packaged / sidecar path, which is the common one. The proxies
in front of the daemon do not reject `fetch` when it is down — they answer.
The web sidecar ends a connection error as a plain-text 502, and Next's dev
rewrite as a plain-text 500 when OD_PORT points at a dead port. Both landed
in the generic `!resp.ok` branch and surfaced as "export request failed
(502)", so the new message this PR added never appeared where it was most
needed.

`state/projects.ts` already had exactly the right detector for this, kept
private. Extracted it to `runtime/daemon-proxy-failure.ts` and used it from
both places rather than growing a second copy.

Kept deliberately narrow: only a 500/502 that is `text/plain` AND carries a
connection errno counts. Widening to any 5xx would relabel genuine upstream
failures as an outage — the same misleading move in the opposite direction,
which is why the original detector was written this way. A JSON 502 stays a
semantic error, and there is a spec pinning that.

* fix(web): honour the unknown contract on both capability cache edges

Two compatibility edges from review (thanks @nettee), both of which broke
the same rule this PR is built on: absent or malformed means unknown, and
unknown must keep the entry visible.

- A resolved `false` outlived the daemon that gave it. If a page cached
  `false` from a headless daemon and then talked to one that stops
  advertising the field — a restart or a downgrade — the stale `false` kept
  hiding the entry against a daemon we are supposed to know nothing about.
  A fresh, valid response with no capability now drops the cache back to
  unknown and tells mounted consumers, so listeners take `boolean | null`.

- The module's own probe trusted the capability without validating the
  envelope it arrived in, so `{version:{capabilities:{slideRenderer:false}}}`
  could publish `false` and hide the entry. That was self-inflicted: the
  validation went missing when this module stopped importing the registry to
  avoid a cycle, and nothing replaced it.

`isAppVersionInfo` now lives here and the registry imports it, so there is
one validator instead of two and the dependency still runs one way only.

Regression specs for both: `false` followed by an absent field returns to
unknown, and a capability in a malformed envelope is refused.

* fix(web): subscribe mounted viewers even when the cache is already resolved

Follow-on from the invalidation added in the previous commit, and the same
mistake one layer out: a viewer that mounted while the answer was already
`false` took the early-return path and never registered a listener, so when
a later valid response omitted the capability and invalidated the cache to
unknown, that viewer never heard it. Its entry stayed hidden against a
daemon the compatibility contract says we know nothing about — exactly the
bug the invalidation was added to prevent, just moved from the cache to the
consumer.

Subscribe unconditionally and only skip the probe when the cache is already
resolved. Spec covers the mounted case the loader-level tests could not:
render with `false` cached and the entry absent, invalidate, entry returns.
Verified red by restoring the early return.

* refactor(web): collapse the capability store into one atom with one writer

Four review rounds found six compatibility edges in this module, all the
same shape: `cached`, `inFlight` and the listener set were separate mutable
pieces, and each fix exposed another pair that could disagree — an
invalidation that forgot the in-flight promise, a validation path that
forgot the invalidation, a subscription that forgot to register. Patching
the seventh instance was not going to be different from the sixth.

So the state is now one snapshot behind one writer. `setSnapshot` is the
only thing that mutates it and always drops any in-flight probe, because a
promise created under a superseded answer can only report something this
state has already replaced. `loadSlideRendererAvailable` clears the promise
on settle rather than only on success, so an unanswered probe stays
retryable. Consumers read through `useSyncExternalStore`, so every mounted
viewer sees the same value including a later invalidation — there is no
longer a subscribe-or-not branch to get wrong.

That makes the two edges from this round unrepresentable rather than merely
fixed: a settled probe cannot be served after an invalidation, and a
malformed payload now routes through the same invalidation path as an
absent capability instead of early-returning past it.

One deliberate asymmetry: a transport failure does not invalidate. It is
not an answer, unlike a malformed body, so it must not erase what we know.
Covered by its own spec.

* fix(web): version probes and treat an unusable answer as unknown

Blocking catch (thanks @nettee): clearing `inFlight` stopped a stale promise
being *served*, but not a request already in the air from writing when it
lands. A probe issued before the boot fetch could resume afterwards and put
its `true` back over the boot answer's `false` — the entry returns on a
headless daemon and the next export hits the 501 this change exists to
gate. That was the half of "collapse the state" I had not done: the writes
were unified, but time was not.

Each probe now carries the generation it was issued under and drops its
result once a write has superseded it, so a request from the old world
cannot write into the new one.

Second, transport and parse failures are no longer the same branch. A 200
whose body will not parse IS an answer, just an unusable one, and the
contract says that means unknown — collapsing it with a dead socket let a
stale `false` survive it and keep hiding the entry. Both the boot fetch and
the direct probe now invalidate on an unparseable OK response while leaving
a known answer alone on a transport failure.

Specs: a deferred probe that resolves after a `false` is recorded, an
unparseable 200 through the boot path, and a transport failure that must
not erase what we know. The first is verified red by removing the guard.

Also replaced a spec that passed without exercising its path — a cached
answer short-circuits the direct probe, so the parse-failure case only
reaches the code through the boot fetch.

* refactor(web): read the capability from app state instead of a client store

Seven review rounds produced eleven findings, and ten of them were in the
client-side capability store rather than in what this PR is actually about.
The pattern was not bad luck: each fix added a variable to reconcile the
existing ones (`cached` -> `+inFlight` -> `+listeners` -> `+generation`),
which widened the interaction surface that produced the next finding. The
latest pair — a parsed JSON `null` throwing past the invalidation, and a
probe's `finally` clearing a newer probe's promise — are both interactions
between those variables, not defects in the gate.

The store existed to avoid prop-drilling a boolean that `App` already holds
in React state from its boot `/api/version` fetch. That trade bought a
tidier diff and paid for it with cache coherence, staleness, concurrency,
retry and subscription lifecycle — a distributed-state problem invented for
a value that was never distributed.

So the store is gone. `App` passes `appVersionInfo?.capabilities
?.slideRenderer ?? null` down the same path `metricsConsent` already takes,
and the gate reads a prop. No cache, no probe, no retry, no generations, no
subscription: every failure mode those rounds found is now absent rather
than fixed.

The three cases the contract cares about — `false` hides, `true` offers,
`null` (unknown) offers — are three renders in `FileViewer.test.tsx`. The
specs that exercised probe lifecycle went with the probe; they were testing
a mechanism, and the mechanism is what turned out to be wrong.

* fix(web): close the two paths that could still reach the 501

Both blocking notes from review, and both are consequences of how I threaded
the capability rather than of the gate itself.

- The design-system detail route mounts `FileWorkspace` directly, so it never
  received the prop and `FileViewer` fell back to its `null` default. Since
  unknown deliberately fails open, a headless daemon kept offering PPTX for
  `system/artifacts/deck.html`. Threaded from the same App-level value the
  project route uses; one source, two callers, rather than a second mechanism
  for the second route.

- `appVersionInfo` came only from the one-shot boot fetch, and that helper
  yields null on any failed or non-OK response. A daemon merely slow to come
  up therefore left the capability unknown for the life of the page — and
  unknown fails open, so the gate was defeated by a startup blip. It is now
  re-asked once the daemon is reachable, which is the narrowest retry that
  fixes it: keyed on the daemon becoming live rather than on a timer, and it
  stops as soon as an answer arrives.

* fix(web): make the capability refetch schedule its own retry

Third incarnation of the same mistake, so worth naming: retrying on a
dependency change cannot recover from a failed probe, because a failure
changes nothing — `appVersionInfo` stays null, `daemonLive` was already
true, neither dependency moves, and no further attempt is ever issued. The
failure is sticky, `slideRendererAvailable` stays unknown, and unknown
deliberately fails open, so a headless daemon keeps offering an export it
can only answer with 501.

The retry now schedules its own next attempt instead of waiting to be
re-triggered: bounded with backoff (500ms / 1.5s / 4s / 10s), stopping the
moment an answer arrives. Bounded because an unreachable daemon is its own
visible problem and must not be polled for the life of the page.

* fix(web): cap the capability retry interval instead of the attempt count

Closing the residual case I flagged in my own audit rather than waiting for
it to be found: with a bounded number of attempts, once they were spent
`daemonLive` never flips again and nothing could ever ask, so the capability
stayed unknown for the life of the page — and unknown deliberately fails
open. That is the same hole the retry was added to close, just with a
smaller window (four consecutive failures against a daemon that had already
health-checked as live).

I argued for bounding attempts to avoid polling for the life of the page. On
reflection that traded a rare slow poll for a permanent fail-open hole,
which is the worse side of the trade. The backoff now settles into a steady
30s interval instead of stopping: it only runs while the answer is unknown,
it stops on the first answer, and it leaves no state the page cannot recover
from.

* fix: forbid caching /api/version and stop the two writers clobbering

Both blocking notes, and the first is a consequence of the change itself
that I should have caught: adding a capability turned `/api/version` from a
near-static fact about the build into an answer that differs by which daemon
is behind the URL. It was still served with no `Cache-Control`, so a browser
or proxy could reuse a pre-swap response and a stale `slideRenderer: true`
would be consumed as authoritative — reopening the export the gate exists to
hide. Now `no-store`, with a spec pinning it.

Second, `appVersionInfo` had two unsynchronised writers: the boot pass and
the capability retry. A boot response that resolved `null` after the retry
had obtained an explicit `false` would erase it and reopen the fail-open
entry; a slow but valid boot response could likewise overwrite a newer
answer. The boot pass now never writes `null` and never overwrites an answer
that is already present — a late boot response is by definition the older of
the two.

* refactor(web): ask for the capability at the point of use

Fourteen findings on this PR, and nearly all of them asserted one property:
the capability must not get stale or stuck, because unknown fails open. That
is a global property, and global properties cannot be established by local
patches — which is why each fix produced the next finding rather than
ending the series.

The property is global because the gate was reading a client-side replica of
remote state. A replica needs an initial fetch, a retry, invalidation, write
ordering, cache directives, multi-writer synchronisation, distribution to
consumers, and a refresh when liveness changes. Every one of those was a
finding here.

Deleting `runtime-capabilities.ts` last time did not end it because it moved
the replica into `App` rather than removing it. This removes it: the export
surface asks the daemon when it opens, uses the answer, and discards it with
the menu. If the daemon is swapped, the next open simply asks again.

Gone with it: the prop threaded through four components, the boot-fetch
coupling, the retry with its backoff, and the two-writer synchronisation.
Kept: the daemon-side capability, `Cache-Control: no-store`, the optional
field tolerance, and the export-reason split.

The pending window still fails open, and that is deliberate — but it is one
round trip inside a single menu opening and nothing carries it forward, so
unlike the replica it cannot get stuck.

* fix(web): start each export-menu opening from an unknown capability

The probe state outlives the menu — the component stays mounted — so the
previous opening's answer was still present on reopen. If the daemon was
replaced in between, the gate decided on an answer describing a daemon that
is gone: a stale `true` offers an export the new daemon can only 501.

My previous commit message claimed the answer was "discarded with the menu".
It was not; only the fetch was tied to the opening. Now the state is reset
when the menu opens, so the claim and the code agree.

The regression spec is not in this commit, deliberately. My first attempt
passed with and without the fix — the second response landed too quickly for
the stale window to be observable, so it asserted nothing. The second
attempt, holding that response open, fails in both directions, which means
my assumptions about the close/reopen path are wrong rather than the code
being wrong. Shipping either would be worse than shipping neither, so the
fix goes in now and the spec follows once I understand that path.

* fix(web): revalidate at the menu-to-modal handoff and state the contract

The blocking finding: clicking the PPTX row closes the menu and opens the
modal in one batched update, and the modal's confirm reads the same
capability value — so a `true` fetched at menu-open outlived its surface and
kept the confirm enabled against a daemon that may since only answer 501.
Because the two booleans flip in one batch, an OR of them never changes
across the handoff; the effect now depends on the booleans themselves, so
the handoff resets to unknown and asks again.

The capability contract is now stated once, at the probe:

  1. An answer never outlives the surface opening that fetched it — every
     opening, including the handoff, starts from unknown and re-asks.
  2. Unknown fails open; only a resolved `false` hides the entry.
  3. The daemon is the authority of last resort: a swap after the probe
     resolves is unclosable client-side, and is caught by the export POST's
     501, which now surfaces the no-renderer message.

Tests: the two vacuous positive cases now hold the response open and assert
against the resolved answer, and the reopen regression I owed exists. Its
three earlier failures had one cause worth recording: a stale `true` is
observationally identical to a pending probe — both fail open — so the
true-then-swap direction asserts nothing. The observable stale direction is
a lingering `false` wrongly hiding the entry on reopen, and that is what
the spec pins. Red-check matrix: removing the reset turns the reopen spec
red; reverting the deps to the OR turns the handoff spec red; mapping an
absent field to false turns the absent spec red.

* fix(web): recognise EPIPE as a proxy connection failure; drop a stray file

Both follow-ups from review.

EPIPE belongs in the errno set: the sidecar's own replay check classifies
exactly ECONNRESET and EPIPE as connection-level, and when a replay is not
applicable the EPIPE surfaces as a plain-text 502 containing `write EPIPE` —
which fell through to the generic "export request failed (502)" instead of
the daemon-down message. The classifier also had no direct tests since its
earlier ones were deleted along with the capability store, so it now has a
dedicated spec pinning both halves of its contract: every connection shape
the proxies actually emit is recognised, and JSON 5xx / errno-less text /
other statuses are not. Removing EPIPE turns exactly its case red.

`__diag.test.tsx` should never have existed in the repo: it was a local
scratch file for probing menu toggle mechanics, swept in by a `git add -A`
in the previous commit. It logged instead of asserting and leaked its fetch
stub into the rest of the file. Deleted.

* fix(web): key the capability probe to the export tab, not the popover shell

The popover shell can open on Share and sit there across a daemon swap;
switching to Export then presented the PPTX entry against the answer from
shell-open time. The export surface's opening is the tab becoming visible,
not the shell opening.

This is the terminating fix for the transition family, and the reason can
be stated as an equivalence rather than a hope: the probe's guard is now
the same predicate the consumers render under — the PPTX row mounts inside
`unifiedActionTab === 'export'` within the open popover, the modal confirm
under `pptxExportModalOpen` — and the effect's dependencies are exactly the
free variables of that predicate. A transition into visibility is by
definition a change of one of those inputs, so it necessarily re-runs the
probe. "A transition we forgot" is no longer a findable category: the
guard cannot be coarser than the render condition, because it is the
render condition.

Regression: false from the first daemon, switch to Share, swap, switch
back to Export with the new answer held open — the entry must show
(unknown fails open) and a second probe must have fired. Reverting the
guard to the popover shell turns exactly that spec red.

* fix(web): retry a failed probe within the opening; stop at a fieldless one

A probe that fails (daemon still starting, transient network) left the
whole opening at unknown, and unknown fails open — so a headless runtime
kept the entry clickable for the lifetime of that opening. Retried now with
a short bounded backoff that is cancelled by any surface transition:
nothing crosses surfaces, so the contract's first clause still holds for
the retry itself.

The distinction that keeps this from over-polling: a FAILED probe is worth
asking again, but a probe that SUCCEEDS without the capability field is an
old daemon — asking again cannot teach us more — so it stops at unknown on
the first valid answer. The two cases were previously collapsed into one
`null`, which is exactly why the earlier background-copy design kept
tripping over them; here they are separated at the only point that knows
the difference (did `fetchAppVersionInfo` return a payload or not).

Regression: first probe answers 500, the entry stays visible (unknown
fails open), the in-opening retry fires, and its held `false` then hides
the entry. Reverting to a single-shot probe turns exactly that spec red.

---------

Co-authored-by: Jason <>
2026-08-24 08:28:18 +00:00
lefarcenandGitHub 7a4175c86f feat(analytics): report why an installed agent CLI cannot be used (#7285)
* feat(analytics): report why an installed agent CLI cannot be used

A user installed DeepSeek Harness correctly and OpenDesign showed nothing.
We only learned about it because they said so and sent a diagnostics zip:
detection is the one stage that knows an installed CLI is unusable, and it
told nobody. There is no way today to ask how many installs are in that
state, or which CLI, or why.

Report each diagnostic detection produces, once per (agent, reason,
version) per page session. Detection runs on cold start and on every
rescan, so counting passes would turn one broken install into dozens of
events; deduped, the number means "installs affected", which is the
denominator a decision here actually needs. Warnings count too — an
`untested-version` spike across many installs is how we would learn our
supported-version list had fallen behind our own installer, which is
precisely what went unnoticed.

The resolved path is deliberately absent: an agent binary path contains
the OS username. `has_path` carries the only part that matters, since the
picker hides a row that has none.

Half the registry was invisible to this before it started. `TrackingCliProviderId`
covered 14 ids while the daemon ships 28, so DeepSeek Harness and a dozen
others collapsed into `other` — a breakdown that cannot name the failing
CLI answers nothing. Every shipped agent now has an id, and a test fails
when a new runtime def arrives without one.

* test(runtimes): assert the shipped agents, not the machine's

The tracking-id guard walked AGENT_DEFS, which appends whatever local
profiles the developer declared in their own agents.local.json. Those ids
are required not to collide with a shipped one, so they correctly collapse
to `other` — and turned anyone's personal config into a failure reading
`expected [ 'my-custom-agent' ] to deeply equal []`, indistinguishable from
the missing-mapper regression the guard exists to catch.

Export the shipped list under a name that says which question it answers,
and assert on that. The unknown-id case stays: a locally declared agent is
not ours to name, and PostHog should never learn an id out of someone's
config file.

* test(runtimes): pin the local-profile case in the repo, not in my shell

The isolation fix was verified by pointing OD_AGENT_PROFILES_CONFIG at a
throwaway file by hand, which proves it once and protects nothing. Build
the profile inside the test instead, so the machine-dependent failure the
guard used to produce cannot come back unnoticed.

Registry lists are built at module load, so the profile is written before
a fresh dynamic import. The case asserts the profile actually reached
AGENT_DEFS before asserting it stayed out of SHIPPED_AGENT_DEFS — without
that, it would pass just as happily if the profile never loaded at all.
2026-08-24 01:42:23 +00:00
lefarcenandGitHub 35a38abf34 fix(daemon): admit one design-system enrichment run per conversation (#7271)
A double-triggered "AI Optimize" affordance created two concurrent
design-system enrichment runs 383 ms apart in one conversation and both
were billed (2026-07-28 incident). Reject the newcomer on the daemon with
HTTP 409 DESIGN_SYSTEM_ENRICHMENT_IN_PROGRESS (details name the owning
run), keep ordinary chat turns ungated, and make the web trigger a
synchronous single-flight call so the duplicate never leaves the client.
2026-08-23 16:35:07 +00:00
lefarcenandGitHub 82c607d70a fix(packaged): report the daemon log tail when startup failure is unparseable (#7291)
The largest startup-failure bucket in production (macOS daemon-start, 968
events across 293 people over the 14 days to 2026-08-22) exits code=1 with
the native module present and error_code / missing_module / daemon_error all
null. The reason was printed in a log tail reportStartupFailure had already
read and then discarded, because parseDaemonLogTail only names shapes we
already know (ERR_*, a missing module, an XError: headline).

When that parse yields nothing, send the tail itself: last 40 lines, scrubbed
of POSIX and Windows home dirs before truncation, hard-capped at 4000 chars
keeping the end (the tail is chronological, so the fatal line sits nearest the
exit). Narrow by design — when the cause is already named the raw tail is
bytes and privacy surface for no new information, so a present value also
means "this log defeated the parser".
2026-08-23 16:05:11 +00:00
114e73de16 chore: retire Feishu group entries, route every community CTA to Discord (#7295)
Replace every user-facing Feishu group entry (README, landing header and
hero community CTA for zh/zh-tw, launch-week page buttons, DeepSeek
Harness guide hero CTA in all locales, client nav-rail social slot for
zh-CN/zh-TW) with the Discord invite, and drop the now-unused Feishu
QR/mark assets, icon, i18n keys, CSS and analytics element value.

Claude-Session: https://claude.ai/code/session_017gVe9HtkaMhcPCvm3aX7Eg

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2026-08-23 10:46:11 +00:00
open-design-release-bot[bot]GitHubopen-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
05f5b33ef5 chore(release): bump main to 0.20.3 ahead of stable 0.20.2 (#7253)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
2026-08-21 20:57:23 +00:00
CaprikaandGitHub 5a79ad8d74 feat(web): move social links to the nav rail footer (#7232)
* feat(web): move social links to the nav rail footer

The Feishu/Discord, X and mail entries used to live inside the
top-right account menu, one popup away from anyone looking for the
community. They now sit permanently in the home nav rail's bottom-left
footer, which previously rendered as a dead white strip.

The community slot follows the client language: Chinese UIs
(zh-CN / zh-TW) link to the OpenDesign Feishu group, every other locale
keeps Discord. X and mail are locale-independent.

* fix(web): keep the signed-out sign-in card off the social row

`.entry-local-mode-tip` was pinned to the rail footer's bottom edge
(position: absolute; bottom: 3px; z-index: 2). That was harmless while
the footer had nothing else in it, but the social row now occupies that
strip, so the opaque card painted straight over the Discord/Feishu, X
and mail links for exactly the signed-out users it is shown to.

Put the card in flow instead (order 2, above the social row's 3) and let
the footer size itself around both.
2026-08-21 08:08:09 +00:00
lefarcenandGitHub c9eb23027f fix(viewer): preserve live HTML previews (#7156)
* fix(viewer): preserve live HTML previews

* fix(viewer): sync saved text to retained preview

* fix(viewer): prewarm retained file revisions

* fix(viewer): expire saved preview adoption

* fix(viewer): repaint retained previews on activation

* fix(viewer): reconcile retained style history

* fix(viewer): renew preview asset scopes without reloads

* test(viewer): cover scope renewal in team previews

* test(exports): expect managed preview bases
2026-08-21 11:41:27 +08:00
open-design-release-bot[bot]GitHubopen-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
8b18210103 chore(release): bump main to 0.20.2 ahead of stable 0.20.1 (#7201)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
2026-08-21 01:32:59 +08:00
lefarcenandGitHub a81d114b1d fix(daemon): anchor run phases on first model event (#7155)
* fix(daemon): anchor run phases on first model event

Phase boundaries anchored on `firstTokenAt`, the first *text* token. A run
that opens with a tool call and stays silent until the end therefore had its
entire tool loop billed to `runtime_init`, leaving the generation phase
holding only the closing sentence. Tool-first is the dominant opening shape
for the OpenCode/OpenAI families, so `bottleneck_phase` reported startup for
runs that were working the whole time.

The decomposition was also not a partition: on the new fixture's 24.2s run the
old boundaries sum to 37.8s, because the tool loop is counted inside both
`runtime_init` and `tool_execution`.

Anchor phases on `telemetry.firstModelEventAt ?? telemetry.firstTokenAt` --
tracer marks only. Deliberately not the nearby `firstModelEventAt` composite,
whose `firstToolUseAt` fallback is scanned off event records on the daemon's
clock and is never reset per retry attempt.

Every published metric keeps its current value; a spec pins the four
first-token metrics so a future edit cannot silently redefine them. Adds
`runtime_init_to_first_model_event_ms`, `model_active_duration_ms`, and
`phase_schema_version` so old and new rows can be filtered apart. The
`stream_output` phase now subtracts tool time to stay mutually exclusive with
`tool_execution`.

Trace side gains a `model-active` phase-diagnostics entry rather than a second
span, to avoid an extra observation row per run.

* fix(daemon): measure the tool phase as wall-clock occupancy

Review found two ways the tool phase could exceed the window it sits inside,
both because it was built from `tool_duration_ms` -- the sum of paired
tool_use -> tool_result spans. That sum is the right published answer to "how
much tool work happened" but it is not elapsed time, so it cannot partition a
window:

- Parallel tool calls sum to more than the clock they occupy. Two overlapping
  4s and 5s tools inside a 6s active window reported 9s of tool execution.
- `run.events` is never cleared between retry attempts, while the phase anchor
  comes from the current attempt's lifecycle marks. A retried run charged the
  previous attempt's tool spans to this attempt's window.
- ACP producers supply their own `startedAt`, which can predate the anchor when
  their clock differs from ours.

In each case the inflated phase won `bottleneck_phase` with a duration longer
than the run had been active, and the previous `Math.max(0, ...)` floor on the
residual hid the symptom rather than fixing the partition.

Derive both `stream_output` and `tool_execution` from the union of tool
intervals clipped to [phaseAnchorAt, runEndAt]. The two are then halves of the
model-active window by construction. `tool_duration_ms` keeps summing paired
spans and is unchanged.

Three fixtures cover the parallel, retry, and producer-clock cases; all three
reported `tool_execution` as the bottleneck before this change.

* fix(daemon): keep the phase anchor on real model output

Second review round found three more ways the new boundaries could publish
wrong attribution.

A daemon-persisted artifact was being marked as the first model event. Agent
`artifact` events are emitted from exactly one place -- the daemon's close-time
persistence of plain-stream stdout -- and never by a runtime relaying model
output. On a plain-stream run that stamps first-token from its first buffered
stdout chunk, that late mark won the anchor and reported a 1s active window and
a 16s runtime init for a run that had been streaming since second 5. That is
the opposite of this PR's intent, and it falsified the claim that the
plain/BYOK family keeps its current behaviour through the first-token fallback:
the fallback was never reached, because the field was set -- just set by the
daemon, at the end of the run.

Fixed at the source (the tracer no longer treats an artifact event as a model
event, and neither does the scan-based fallback that feeds
`first_model_event_type`), and again as an invariant: the anchor is the
EARLIEST of `firstModelEventAt` and `firstTokenAt`, because the model cannot
have started responding after it emitted its first token. A late producer mark
can no longer drag the boundaries to the end of the run.

`phase_timing_status` still required `firstTokenAt`, so a fully instrumented
tool-only turn -- every boundary the phases are measured from present, no text
ever emitted -- was stamped `partial` and dropped by dashboards filtering on
complete timings. It now follows the anchor the phases actually use. The
first-token fields are unchanged.

Tool occupancy ignored a tool still outstanding at run end, handing the whole
active window to `stream_output` while `last_observed_phase` said
`tool_execution`. Outstanding spans are now closed at run end for phase
purposes; `tool_duration_ms` still sums completed pairs only.

Five fixtures, red before the change, including one in the tracer suite
pinning that an artifact event is not a model event.

* fix(daemon): scope outstanding tool spans to the current attempt

Closing every still-open tool span at run end interacted badly with the retry
path. `run.events` survives a retry while lifecycle telemetry does not, so an
attempt whose child was killed mid-tool leaves an unpaired tool_use behind.
Closing that at run end stretched it across the retry boundary: a tool_use at
3s, an attempt-2 anchor at 20s, and a run ending at 25s clipped to a 5s
`tool_execution` phase for an attempt that never called a tool.

Gate the close on the daemon-clock timestamp the tool_use was OBSERVED at,
which shares a clock with our own marks, rather than on its start. A
producer-supplied `startedAt` can legitimately predate the anchor on a tool
that is genuinely running, and clipping already handles that case -- gating on
the start would discard real occupancy instead.

Two fixtures: the killed-prior-attempt case (red before this change), and a
guard that an outstanding tool with a skewed producer start is still counted,
so the gate cannot be tightened into discarding live work.

* fix(daemon): give the summarizer a real attempt boundary

Both remaining review findings came from the same gap: the outstanding-tool
gate used `phaseAnchorAt` as a stand-in for "when this attempt started", and
those are different instants.

`phaseAnchorAt` falls back to `firstTokenAt` when no model-event mark exists.
On a legacy or recovery tool-first run -- tool_use at 4s, first text at 10s,
run ending at 30s -- the gate then discarded a tool this attempt had issued and
never finished, reported zero tool occupancy, and handed the whole active
window to `stream_output`. That is the exact shape this change exists to
measure. The gate now uses the attempt boundary; `phaseAnchorAt` is only used
for clipping.

For that boundary to exist, two producers had to stop destroying it:

- The retry path called `resetForAttempt`, which stamps `attemptStartedAt` and
  `attemptIndex`, and then replaced `run.analyticsTelemetry` wholesale, so both
  were dropped every time. It now spreads, keeping the `run.createdAt` fallback
  for the logical run start. This also means `attempt_index` and
  `attempt_duration_ms` -- already-published fields -- start reporting across
  retries instead of silently vanishing.
- `prepareRestart` reused the run for a recharge resume and cleared its
  terminal state but left `analyticsTelemetry` untouched, so a resumed attempt
  inherited attempt 1's `firstModelEventAt`/`firstTokenAt`. Every boundary then
  measured from before the recharge pause, putting the wait inside the new
  attempt's model-active window. It now resets the marks, preserving only
  `startRequestedAt` so queue time is still measured from the user's request.

Two fixtures, red before the change: a no-model-event outstanding tool, and a
recharge resume asserting the stale marks are gone while the logical run start
survives.

* fix(daemon): anchor ACP tool-first turns at the tool's start

Three findings from the fifth review round.

ACP accumulates `tool_call` frames and emits the canonical `tool_use` only once
the call reaches terminal status, carrying `startedAt` set to the first frame's
arrival. The universal `send()` path stamped the anchor with `Date.now()` when
that delayed event arrived, so a tool-only ACP turn put its anchor at tool
COMPLETION -- measuring the whole tool loop as runtime init. That is precisely
the case this PR exists to fix, still fully present for the ACP family. The
stream-event markers now carry the producer's `startedAt` (daemon clock, same
domain as our own marks) and the tracer stamps that instead of arrival time.

Tool ids are reused across a retry: sequential ids like `call_0` restart in the
retry's fresh session, and `openTools` is keyed by id alone. A killed
attempt's still-open `call_0` therefore merged with the retry's genuinely new
`call_0`, pairing attempt one's opening with attempt two's close and reporting
a 19s tool that never ran -- inflating `tool_duration_ms` as well as phase
occupancy. A still-open entry observed before the current attempt began is now
replaced rather than merged.

The Langfuse `model-active` diagnostic used different boundaries from the
metric it exists to be compared against: it preferred `firstModelEventAt` over
the earliest mark, and ended at `finalizeStartAt` rather than run end. On a
text-first stream those differ, so the same run reported two windows. It now
mirrors the canonical rules; the shared "earliest finite mark" logic is a named
helper.

* fix(daemon): stop reporting phase attribution we cannot support

Two findings from the sixth review round.

The first-model-event mark was first-write-wins, which is wrong for ACP. It
holds each toolCallId until terminal status, so two parallel calls can complete
in the opposite order they started: a call that began at 200ms can be the first
one we hear about, and the call that began at 100ms is then discarded. Every
phase boundary shifted later by the difference. The mark now keeps the earliest
finite timestamp with its type. Marks without a producer timestamp default to
arrival, which is monotonic, so this only ever moves the anchor earlier.

Phase occupancy is reconstructed from tool frames in `run.events`, which is a
bounded ring buffer. A run that opens with a 20s tool and then emits enough
output to evict it reconstructs zero occupancy, hands the whole active window
to `stream_output`, and -- because lifecycle marks live on the run and survive
eviction -- still stamped the bundle `complete`. The summarizer now reads the
same truncation signal the execution diagnostics in `runtimes/runs.ts` already
use (first surviving event id is no longer 1), downgrades a `complete` phase
bundle to `partial`, and withholds `bottleneck_phase` entirely rather than
naming whichever phase the buffer happened to keep. Timings that come from
lifecycle marks are unaffected and keep reporting.

Five fixtures. Two of them were green before their change and exist to pin the
boundary from the other side: a later event must not move the anchor forward,
and a truncated run must still report its mark-derived timings.

* fix(daemon): gate every tool interval on the opening attempt

The pairing path never checked which attempt opened the tool. A `tool_result`
that arrives after a retry has started -- a buffered stdout flush racing the
abort, which the ACP session code already documents as a real sequence -- was
paired with its attempt-1 opener into a single interval spanning the retry
boundary. Clipping then charged the new attempt 8.9s of tool execution inside a
9s window for an attempt that issued no tool at all.

Earlier rounds gated the two other ways a stale entry reaches an interval (id
reuse on open, close at run end) but did so at each site, which is why this
third path was missed. The rule is now stated once as `openedInCurrentAttempt`
and every append to `toolIntervals` goes through it, so a fourth path cannot
quietly skip the check.

`tool_duration_ms` deliberately does not pass through the gate and keeps its
whole-run paired-sum definition; the fixture asserts that value explicitly so
the distinction stays deliberate.

* fix(daemon): stop moving a published metric, and abstain when tools are unattributable

Two findings from the seventh review round.

Passing the producer's start into `markFirstModelEvent` moved
`time_to_first_model_event_ms`, which this PR promised not to touch. That field
means "when we first saw a model event"; for an ACP tool starting at 4s and
emitted at terminal 20s it began reporting 4s instead of 20s. My own invariance
fixture did not catch it because it only pins the four first-token metrics.

The mark is now split. `firstModelEventAt` goes back to first-write-wins on
arrival and keeps feeding the published metric unchanged. A new
`firstModelResponseAt` holds the earliest producer start (clamped to arrival, so
a fast producer clock cannot claim the model responded in the future) and is
what phase boundaries anchor on. The new phase field is renamed to
`runtime_init_to_first_model_response_ms` to match what it measures; it is
introduced by this PR and has never shipped.

Second, a tool id shared by two attempts cannot be attributed. When a retry
reuses an id whose previous opener is still outstanding, a later `tool_result`
could belong to either, and the two readings differ by seconds of occupancy.
Earlier rounds tried to pick a winner; nothing in the event log supports one.
The summarizer now records that an opener was displaced, marks the ledger
ambiguous when a result arrives for such an id, and withholds
`bottleneck_phase` and a `complete` phase status -- the same treatment
truncation already gets, and for the same reason. Timings derived from
lifecycle marks keep reporting.

Fixing this properly rather than abstaining needs an attempt or generation id
carried on the tool events themselves, which is a producer and event-schema
change; noted in the review thread rather than smuggled in here.

* fix(daemon): share one phase-anchor rule between both sinks

The Langfuse `model-active` diagnostic listed its own marks, so adding
`firstModelResponseAt` to the analytics anchor last commit left the mirror
behind again. On an ACP tool-first run with no text token it reported 10s
against the analytics value of 26s for the same run -- on precisely the runtime
shape this change targets, and in the one entry whose whole purpose is to be
compared across sinks.

This is the second time the two drifted, so the fix is structural rather than
another manual sync: the rule is exported as `phaseAnchorFromMarks` and both
call sites use it. Re-listing marks per sink is what allowed the drift, and a
third mark would have caused it again.

Validity rules are now identical by construction too, which resolves the
separate half of the finding.
2026-08-20 08:45:45 +00:00
CaprikaandGitHub 3662637d38 fix(media): surface concrete image-generation failure reasons (#7118)
* fix(media): surface concrete generation failures

* fix(media): preserve error code underscores in chat

* test(contracts): expect concrete media failures

* fix(media): flush structured CLI failures
2026-08-20 04:01:11 +00:00
lefarcenandGitHub a50a71b656 fix(viewer): stabilize retained HTML previews (#7128)
* fix(viewer): stabilize retained HTML previews

* fix(viewer): finish motion on direct slide seeks

* Revert "fix(viewer): finish motion on direct slide seeks"

This reverts commit 856eff1849.

* fix(viewer): keep direct slide seeks same-document

* test(viewer): keep remount cache assertion scoped

* fix(viewer): never mask rendered srcdoc previews

* fix(viewer): recover aborted blob previews immediately

* fix(viewer): retry aborted previews after reactivation

* fix(viewer): preserve keyboard-only deck state
2026-08-19 13:13:04 +00:00
lefarcenandGitHub 16fad1c4d8 revert(design-systems): remove structured runtime workflow (#7110)
* revert: remove structured design system runtime

* fix: preserve authority boundaries during revert
2026-08-19 05:37:48 +00:00
Ray XiandGitHub 0d53c96beb fix daemon HyperFrames scaffold and runtime (#7030)
* fix daemon HyperFrames scaffold and runtime

* fix CI snapshots and Nix hashes

* fix(pack): preserve HyperFrames sharp runtime

Copy the target-native sharp closure into final standalone payloads and execute the packaged HyperFrames CLI during afterPack.

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)

* test(pack): avoid chmod on linked Node fixture

Only chmod the copied fallback so a successful hard link does not try to change the CI-owned Node inode.

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
2026-08-19 03:16:46 +00:00
lefarcenandGitHub 879236e614 fix(workspace): restore last selection after restart (#7067)
* fix(workspace): restore the last selection on restart

* test(workspace): cover persisted restart default

* fix(workspace): reuse directory authority for context reads

* test(workspace): align unknown seat invite behavior

* test(workspace): cover unknown seat invite fallback

* fix(workspace): preserve unknown invite capacity

* fix(workspace): commit selection after persistence

* fix(workspace): preserve unknown seat analytics

* fix(workspace): guard stale selection cleanup

* fix(workspace): make stale recovery atomic
2026-08-18 16:12:14 +00:00
Ray XiandGitHub aa5bb5d67a fix: persist public file shares across daemon restarts (#7002)
* fix: persist public file shares across restarts

* fix(daemon): redact unpersisted public snapshots

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)

* fix(web): expose public share recovery action

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
2026-08-18 15:49:39 +00:00
lefarcenandGitHub 42faa84d0c fix(web): recover aborted srcdoc previews (#7055)
* fix(web): recover aborted srcdoc previews

* fix(web): parse only top-level stylesheet imports

* fix(web): preserve font import conditions

* fix(web): latch srcdoc parser completion

* fix(web): defer recovery while srcdoc parses

* fix(web): confirm blank previews before reporting

* test(e2e): cover preview white-screen monitoring

* test(e2e): select the requested preview file
2026-08-18 14:24:45 +00:00
lefarcenandGitHub 67576cb0b6 fix(collab): keep workspace projects usable during outages and first open (#7027)
* fix(daemon): decouple local data plane from workspace authority

* test(e2e): align workspace authority contracts

* fix(daemon): preserve read-only team mirrors offline

* test(daemon): preserve retryable result literal

* fix(web): keep projects usable during workspace outages

* test(web): keep collaboration mounted during outages

* test(daemon): keep viewer comments local during outages

* fix(collab): open team projects during materialization

* fix(collab): keep project placeholders fail-closed
2026-08-18 11:36:14 +00:00
PerishFireandGitHub ab77eafeb1 refactor(release): retire dedicated betas and preview lanes (#7058)
* refactor(release): retire dedicated betas and preview lanes

* fix(updater): consume dynamic release channels

* fix(e2e): narrow counted release versions structurally
2026-08-18 11:27:28 +00:00
833e1a20d1 fix(daemon): carry Vela image safety refusals through to the client (#6993)
* fix(daemon): carry Vela image safety refusals through to the client

A refused image request and a provider outage arrived at the user as the
same sentence. `vela image gen --json` prints the complete task object,
including its structured `error` block, and only then exits non-zero — but
`runVelaCommand` rejected with the bare exec error and dropped that stdout,
so the verdict was already gone before any caller could read it.

Attach the failed command's stdout to the rejection, decode it into a typed
`VelaMediaError` carrying `code`, `subject` and `retryable`, and let those
fields reach the persisted task snapshot the way `code` already did. The
prompt contract gains a third outcome so the assistant stops calling a
refusal an outage; every other failure keeps its existing wording.

Classification reads the structured `code` only. An unrecognised code, a
non-JSON stdout, or safety wording in a message all keep their original
error, because mislabelling an outage as a policy refusal sends a user off
to rewrite a prompt that was never the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t

* test(daemon): prove a refusal survives the process boundary and reaches the agent

The existing suite builds its rejections by hand, which exercises the decoder
but assumes the thing that was actually broken: that a non-zero exit still
carries the CLI's stdout back. These two suites remove that assumption.

The first spawns a real stand-in `vela` that reproduces the CLI's failure
protocol -- task JSON on stdout, human line on stderr, exit 1 -- and asserts
renderVelaImage still yields the structured verdict with its subject. A crash
with no task output stays an ordinary failure, so a verdict is never invented
from nowhere.

The second spawns the real `od media wait`, because its stdout is the agent's
entire view of the outcome. If the verdict were flattened there, no prompt
wording and no interface could recover it -- the agent would be choosing its
reply from a fact it was never told. It also pins that an unrecognised field
passes through, so a server-side addition does not need a CLI release, and
that an ordinary failure does not arrive wearing a refusal's clothes.

What these do not prove, and no test here can: that the assistant then picks
the refusal sentence. That is prompt adherence, not plumbing. The interface
still has no deterministic surface of its own -- see the PR's adjacent issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t

* fix(daemon): apply the refusal wording to the prompt the daemon renders

Two review findings, both real, both mine.

The refusal branch went into packages/contracts, but composeSystemPrompt
imports MEDIA_USER_REPLY_CONTRACT from the daemon's own copy in
apps/daemon/src/prompts/. Before this change the two were byte-identical,
and nothing imports the contracts copy at all -- so the edit looked like a
behaviour change and was none. The primary agent flow kept describing a
content-safety refusal as a temporary outage, which is exactly the symptom
the PR set out to remove.

The duplication is the actual defect, so a guard test now pins the two
copies equal and fails if they drift again. Deleting one of them is the
better fix and is left as its own change; a mirror test is the cheap thing
that stops a repeat today.

Second: normalizeError reconstructed only message, status and code, so
subject and retryable survived until the first daemon restart or cache
rehydration and then vanished from /api/media/tasks/:id/wait. The read path
now validates both against the same vocabulary the write path uses, and an
absent retryable stays absent -- "the producer did not say" and "retrying is
pointless" are different answers to a client.

The mocked wait test could not have caught this: it never touches SQLite.
The round-trip cases added here do, covering insert, update, every subject,
and rejection of an unknown subject or a non-boolean retryable arriving from
a row this build did not write.

Snapshot update is totalChars only -- the section gating matrix is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t

* fix(daemon): surface provider image errors

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:22:14 +00:00
f1a0b60c6c chore: unify product name display to OpenDesign repo-wide (#6998)
* chore: unify product name display to OpenDesign repo-wide

Rename the user-facing product name from "Open Design" to "OpenDesign"
across documentation, web/desktop UI strings, i18n locales, prompts,
plugin/skill/design-system content, changelogs, and specs, matching the
landing-page rename already in flight.

Deliberately excluded so installed-app identity stays untouched:
- packages/release PRODUCT_NAME and everything derived from it
  (app bundle names, installer/registry identity, updater feeds)
- tools/pack, tools/release, apps/packaged, landing page (PR #6983)
- mocks/ recorded traces and LICENSE
- test fixtures and comments that encode real on-disk install paths
  (Open Design.app, Program Files\Open Design, AppData paths)

Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host
test runs over the renamed strings.

* fix(web): keep two-letter author monogram for CamelCase brand names

authorInitials split words on whitespace only, so the renamed single-token
author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper
CamelCase boundaries as word breaks so the avatar fallback stays "OD".

* fix(release): restore spaced Windows source artifact names in win.ps1

tools/pack still derives builder outputs from the unchanged PRODUCT_NAME
("Open Design"), so the asset script must look for the spaced filenames.
Matches the mac/mac-intel/linux asset scripts, which were already kept on
the original names.

---------

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2026-08-18 07:09:45 +00:00
lefarcenandGitHub 1a7a23d4cc fix(community): make deck remix previews reliable (#6996)
* fix(community): make deck remix previews reliable

* fix(community): support nested deck scrollers

* fix(web): isolate nested deck fallback scrolling

* fix(web): infer deck scroll axis from slide geometry
2026-08-18 05:17:00 +00:00
dfc63955b8 feat(analytics): report files_written_count on run_finished (#6977)
artifact_count deliberately counts only renderable outputs (HTML +
image/video/audio), so a run whose deliverable was PROMPTS.md or
report.docx reported artifact_count: 0 and was indistinguishable from a
pure chat turn in the artifact funnel. A 2026-08 sample of
"artifact_count = 0" successful runs on 0.18–0.19 found ~1/3 had
actually written such non-renderable files.

files_written_count counts distinct files of ANY type the run created
or modified. Primary source is the filesystem snapshot diff (the
snapshot walk now stamps non-tracked files with a size+mtime-only
fingerprint under a separate 5000-file cap, so code-heavy imported
repos cannot starve artifact fingerprinting and no extra content
hashing happens at run finish). When no baseline exists the tool-stream
fallback (countWrittenFiles) and the truncation-proof side-effect
ledger (writtenFilePaths) report it with the usual per-agent caveats.

artifact_count semantics are unchanged.

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-08-18 00:51:30 +00:00
db6db34859 add od lint — CLI half of the artifact lint endpoint (#6959)
* feat(cli): od lint — CLI half of the live artifact linter

POST /api/artifacts/lint has been reachable by the web layer since the
linter landed, but had no SUBCOMMAND_MAP entry, leaving it invisible to
external agents in violation of the dual-track rule. Adds the shared DTO
in contracts and a runLint mirroring runExport's flag shape (stdin via
'-', --json envelope, --fail-on threshold, exit 1 on findings at/above).

* address review: contracts as producer authority, cli-lint entrypoint spec, root-help discoverability

- lint-artifact.ts now imports ArtifactLintFinding/ArtifactLintSeverity
  from @open-design/contracts and aliases the legacy exported names to
  them, so lintArtifact() is typed by the shared contract and a producer
  change that breaks the wire shape fails compilation instead of
  drifting behind the CLI's consumer-side assertion. The lint route
  types its request/response against LintArtifactRequest/Response.
- New tests/cli-lint.test.ts on the cli-templates/cli-deploy stub-server
  harness: file + stdin input, all four --fail-on thresholds, clean and
  finding responses, human/--json/--agent-message output, usage errors
  (unknown flag, bad threshold, missing/empty file), HTTP failure
  (structured exit 64), unreachable daemon (exit 3), and root-help
  presence. 12 specs.
- printRootHelp() lists od lint beside od export with its headless/
  threshold purpose.

---------

Co-authored-by: Cruz Morales <273886418+DojoGenesis@users.noreply.github.com>
2026-08-17 08:15:52 +00:00
4034380285 fix(security): reject loopback for BYOK asset download URLs (#5478) (#6072)
* fix(security): reject loopback for BYOK asset download URLs (#5478)

The assertExternalAssetUrl guard previously routed through
validateBaseUrlResolved without forbidLoopback, so asset URLs pointing
at 127.0.0.1, localhost, or ::1 passed validation and were fetched by
the daemon. This allowed a compromised or misconfigured upstream gateway
to exfiltrate data from internal services via SSRF.

Changes:
- Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts)
- validateBaseUrl rejects loopback hosts when forbidLoopback is set
- validateBaseUrlResolved skips the loopback early-return when forbidLoopback
- validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved
  loopback addresses are also rejected (not just literal loopback hosts)
- assertExternalAssetUrl now passes forbidLoopback: true
- User-configured provider endpoints (validateUserProviderBaseUrl) are
  unaffected — they still allow loopback for local gateways
- Adds regression tests covering literal loopback rejection, DNS-resolved
  loopback rejection (IPv4, IPv6, mixed results), and provider endpoint
  exemption

Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the
separate BYOK asset-download path (assertExternalAssetUrl) that retains
the loopback carve-out.

Closes #5478

* fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478)

The validation step (validateBaseUrlResolved) and the fetch step
(assertAndFetchExternalAsset) previously performed independent DNS
lookups. A DNS-rebinding domain could return a public IP for validation
and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard.

Fix:
- validateBaseUrlResolved now attaches the DNS-resolved addresses that
  passed validation to its return value (BaseUrlValidationResult.resolvedAddresses)
- assertExternalAssetUrl passes these addresses through on the ok branch
- assertAndFetchExternalAsset creates an Undici Agent with a custom
  connect.lookup that returns only the validated addresses, pinning the
  TCP connection regardless of any DNS rebind between validation and fetch
- 3 call sites in byok-tools.ts that used validate-then-fetch separately
  now route through assertAndFetchExternalAsset for a single unified path
- 4 regression tests covering: single validated address, DNS-rebind mock
  (public→loopback), round-robin DNS, and IP-literal short-circuit

* fix(security): fail-closed on DNS error + connection-time validating lookup (#5478)

Addresses mrcfps third review: the fallback to unpinned fetch when
validation did not attach resolved addresses left a DNS-rebinding hole
where an attacker could make the validation lookup throw (ENOTFOUND /
SERVFAIL) and then answer loopback for the fetch lookup.

Changes:

1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup
   failures now return a forbidden result instead of the sync success.
   This prevents the fail-then-rebind vector.

2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback.
   Non-IP-literal hostnames without resolvedAddresses now throw — the
   fetch never happens. IP literals are safe because they were validated
   synchronously and have no hostname to rebind.

3. Replaced the per-request pinned Agent with a long-lived Undici Agent
   (assetDispatcher) whose connection-time lookup (createAssetValidatingLookup)
   rejects any non-public address. This is defense-in-depth on top of the
   pre-validation, following the same pattern as brands/safe-fetch.ts and
   plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the
   keep-alive socket leak of per-request Agents.

4. createAssetValidatingLookup is exported for unit testing.

5. Expanded test coverage:
   - DNS failure → fail-closed (forbidLoopback true and false)
   - createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP;
     allows public
   - assertAndFetchExternalAsset: throws on blocked URLs and internal IPs
   - Updated existing public-CDN tests to use IP literals (fail-closed
     behavior now correctly rejects hostnames when DNS is unavailable)

* fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478)

Addresses mrcfps 4th review: daemon CI was red because fail-closed
behavior caused hostname-based test fixtures to NXDOMAIN, and
undiciFetch bypassed vi.stubGlobal('fetch') stubs.

Changes:

1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept
   an optional injectable `lookup` (DnsLookupFn) and `fetchImpl`
   parameter. Production callers pass neither, so default DNS and
   globalThis.fetch are used. Tests can inject mock lookups/fetch.

2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch)
   so vi.stubGlobal('fetch', ...) stubs in existing test suites
   (byok-tools, aihubmix, proxy-routes, senseaudio) still intercept.

3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34
   (public IP literal) in 6 test files (41 substitutions) so IP
   literals skip DNS resolution and don't trigger fail-closed on
   NXDOMAIN .test hostnames.

4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset
   with injected lookup + fetch:
   - Validation lookup public → fetch called with redirect:error
   - Validation lookup throws → assertAndFetchExternalAsset rejects
     without invoking fetch (fail-then-rebind vector closed)
   - IP literal → skips DNS, calls fetch directly

5. assetDispatcher + createAssetValidatingLookup kept for production
   connection-time defense-in-depth (exported, available for future
   wiring to a production fetch dispatcher).

Total: 31 tests in asset-ssrf-loopback.test.ts, all passing.

* fix(security): attach validating dispatcher to asset fetch (#5478)

mrcfps 5th review: assetDispatcher was built but never attached to the
fetch call. The validating lookup only runs at connect time through the
dispatcher, so without it the fetch re-resolves the attacker-controlled
hostname and a public-then-loopback rebind still reaches the daemon.

Fix: attach assetDispatcher to the RequestInit via the dispatcher key
(same pattern as plugins/plugin-asset-cache.ts safeExternalFetch).
Production globalThis.fetch (Node/undici) uses it to refuse connect-time
non-public addresses. Test stubs still see redirect:'error' and can
ignore dispatcher.

Also fixed the misnamed TOCTOU test to assert dispatcher is present on
the captured RequestInit, and removed unused undiciFetch import.

* fix(tests): update dispatcher assertions for assertAndFetchExternalAsset

assertAndFetchExternalAsset now attaches a validating assetDispatcher to
the fetch RequestInit (issue #5478). Tests that blanket-checked
init?.dispatcher === testMock fail because asset-download calls correctly
carry the validating dispatcher instead.

Fix: conditional assertion — when init.redirect === 'error' (asset download
path), check dispatcher exists rather than matching the test mock. API
calls still verify the caller's dispatcher is forwarded unchanged.

* fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override

Address mrcfps's non-blocking review items:

1. Replace the redirect==='error' heuristic with URL-keyed assertions.
   The heuristic silently dropped the submit dispatcher identity check
   on the AIHubMix video path (its submit hop also sets redirect:'error').
   Submit/poll hops now assert toBe(callerDispatcher); the asset hop
   asserts toBe(getAssetValidatingDispatcher()) — exported from
   connectionTest.ts so the exact validating instance is compared.

2. Document the intentional dispatcher override on the
   BYOKToolContext.requestInit JSDoc: asset downloads through
   assertAndFetchExternalAsset intentionally never ride the turn proxy
   dispatcher; submit/poll hops keep it.

---------

Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com>
2026-08-17 06:59:31 +00:00
lefarcenandGitHub 9475b9817f fix(web): make deck thumbnails drive deck-stage previews (#6950)
* fix(web): sync deck-stage thumbnail navigation

* fix(web): keep prototype annotations out of deck navigation

* fix(web): distinguish legacy deck slides from annotations
2026-08-17 06:49:05 +00:00
lefarcenandGitHub eee03776fb fix(daemon): make streamed message persistence append-only (#6952)
* fix(daemon): make streamed message persistence append-only

* fix(daemon): materialize event batches in transcript export
2026-08-17 02:55:04 +00:00
open-design-release-bot[bot]GitHubopen-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
84c8f0ee0f chore(release): bump main to 0.19.2 ahead of stable 0.19.1 (#6902)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
2026-08-14 09:48:02 +00:00
lefarcenandGitHub 696f39263c fix(web): show creator identity on recent projects (#6857) 2026-08-14 09:45:25 +00:00
bdad26cfcb fix(platform): search Nix profile bin dirs for CLI discovery (#6121) (#6885)
* fix(platform): search Nix profile bin dirs for CLI discovery (#6121)

The packaged desktop app does not discover agent CLIs installed through
nix-darwin environment.systemPackages because launchd provides a minimal
PATH that excludes Nix profile directories.

Add three standard Nix bin paths to wellKnownUserToolchainBins:

- /run/current-system/sw/bin (system-wide NixOS/nix-darwin profile)
- /nix/var/nix/profiles/default/bin (default system profile)
- ~/.nix-profile/bin (user's active Nix profile)

Since this list is the single source of truth consumed by both the daemon
runtime resolver and the packaged sidecar PATH builder, all Nix-installed
agent CLIs are now detected after Rescan.

Closes #6121

* fix: gate system-wide Nix paths behind includeSystemBins (review)

Move /run/current-system/sw/bin and /nix/var/nix/profiles/default/bin
behind the includeSystemBins flag so sandboxed runs with OD_AGENT_HOME
do not reach host-installed Nix tools. Keep ~/.nix-profile/bin (home-
relative) always included.

Add test verifying system Nix paths appear only when includeSystemBins
is true and are omitted when false.

---------

Co-authored-by: CVE-Hunter-Leo <CVE-Hunter-Leo@users.noreply.github.com>
2026-08-14 07:22:10 +00:00
7a37c1462d feat(export): add reliable standalone HTML export (#6855)
* feat(export): add standalone HTML export

Bundle project-local HTML, CSS, JavaScript, images, fonts, workers,
and iframe dependencies into one offline-safe file.

Expose the same contract through the web download flow and od CLI,
with structured failures and bounded output assembly.

* fix(daemon): honor standalone source URL semantics

Resolve document assets against local base href values, reject browser-invalid TypeScript and JSX sources with a structured 422, and count unquoted first-level resources before graph traversal.

Validated with daemon tests, workspace typecheck, and guard.

* fix(daemon): handle standalone data scripts and CSS URL tokens

Preserve non-JavaScript script payloads as data blocks and scan CSS values without treating strings or comments as project dependencies.

Validated with focused and full daemon tests, workspace typecheck, and guard.

* fix(daemon): classify standalone export resources correctly

Preserve non-HTML iframe MIME types, inline only resource-bearing link relations, and propagate unexpected Vite dist read failures instead of silently falling back.

Validated with focused export tests, the full daemon suite, guard, and workspace typecheck.

* fix(export): preserve version and stylesheet semantics

Hide standalone HTML export for historical versions until dependency snapshots are available, avoiding an action that always fails.

Keep disabled and alternate stylesheets as embedded link elements so standalone export preserves activation semantics. Validated with focused red/green coverage, the full web suite, pnpm guard, and pnpm typecheck.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-08-14 07:21:21 +00:00
lefarcenandGitHub f9c07517b7 fix: make DeepSeek Harness cancellation authoritative (#6880)
* fix deepseek harness cancellation lifecycle

* fix DeepSeek canceled status rendering

* fix DeepSeek cancellation handshake race

* feat support reasoning selection in composer

* test DeepSeek pre-execute cancellation protocol

* test reasoning selector visual surface
2026-08-14 06:40:10 +00:00
lefarcenandGitHub eea8a8522d fix(collab): bound workspace refresh storms (#6871)
* fix(collab): bound workspace refresh storms

* fix(collab): push workspace directory freshness

* test(collab): follow identity-aware hub callbacks

* fix(collab): reset authority on account changes

* fix(collab): fence workspace identity transitions

* test(collab): guard full identity reset
2026-08-14 02:19:51 +00:00
f9fe73c836 feat: add DeepSeek Harness support (#6874)
* feat(daemon): scaffold DeepSeek Harness profile protocol

* feat(daemon): integrate DeepSeek Harness profile runtime

* fix(daemon): preserve headless prompts on native resume

* refactor(daemon): model Harness profile resume explicitly

* fix(daemon): resume Harness sessions after cancel

* test(daemon): verify DeepSeek Harness Windows carrier

* test(daemon): reap DeepSeek Harness Windows process tree

* feat(agents): surface DeepSeek Harness models and branding

* fix(daemon): surface Harness credential guidance

* feat: add bundled DeepSeek Harness setup

* fix: link DeepSeek Harness installer

* fix: link official Harness docs

* fix(daemon): distinguish Harness companion install

* test(pack): verify bundled Harness resources on Windows

* fix: harden Harness profile turn lifecycle

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(dsh-runtime): stop cancelled restore follow-ups

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(daemon): cancel Harness startup before execute

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(pack): build DeepSeek runtime on cold cache

---------

Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Looper <looper@noreply.github.com>
2026-08-14 02:07:55 +00:00
lefarcenandGitHub 9a402b2e4c feat(campaign): extend the DeepSeek V4 campaign to Pro + Flash (#6861)
* feat(campaign): extend the DeepSeek V4 campaign to Pro + Flash

The V4 Flash free week ends 8/13 20:00; a two-week window offering BOTH
V4 Pro and V4 Flash opens at that same instant and runs to 8/27 20:00.

This grows the campaign already in production rather than landing a
second stack beside it. That implementation ran the free week for a
week: its audience resolution (wallet balance is not a subscription),
i18n wiring, localized countdown, frequency control and attribution are
all already exercised, and it carries a product decision this change
must not undo -- the URL review backdoors (campaign / audience / usage
overrides) were deliberately removed, and reviewing the campaign is done
by overriding `window.startAt`, never through query parameters.

Almost all of it is the campaign module growing a second model:
`isDeepSeekV4FlashCampaignModel` now matches either id, which is why the
model switcher badges both without its rendering changing at all, and
the modal's benefit line is an i18n key, so naming both models was copy.
`「立即使用」` now switches a paid user to Pro, the headline benefit.

Two things needed real edits. Model-benefit impressions fired once with
a hardcoded Flash id; product compares the two models' reach separately,
so they now fire once per campaign model actually on screen, each with
its own `model_id`. And `campaign_id` / `element` move to the spec's
`deepseek_v4_pro` naming, with `deepseek_v4_flash` kept declared so the
finished campaign's warehouse rows stay valid.

Copy is rewritten across all 19 locales -- dates, the two-week framing,
both model names, and the peak-hours queue caveat product added. Dates
follow each locale's existing format rather than one imposed format, so
only the day numbers move.

Frequency control is keyed on the campaign id, and the id is new, so a
user who dismissed the free week is still owed this campaign's single
showing. That was an invisible dependency on remembering to change the
id; it now has a test.

Module and i18n key names keep their `…V4Flash` spelling: renaming 28
keys across 19 locales mid-campaign is risk without user-visible
benefit, and belongs in a cleanup pass once the campaign is off.

* fix(web): read a rolling model window as a wait, not a failure

vela's hosted gateway rejects a request that exhausted its rolling
per-model window with `model_limit_exceeded`, phrased for API callers as
"You have reached the 5-hour usage limit for X. Try again after <instant>.
This request was not charged to Wallet Credits."

Two things went wrong with that sentence on the way to a user.

The daemon's `isHardQuotaText` matches its "usage limit" wording, so a
window that resets on its own was classified as `hard_quota` — an
exhausted quota, marked non-retryable. Nothing was charged and nothing
needs topping up, so that both misnames the cause and counts a self-
healing wait against reliability as a real failure.

The web runtime then had nowhere to put it. The AMR branch of
`resolveRunFailureUi` ends in a catch-all returning the generic "task
failed" title with a null message key, which renders the raw upstream
string — so a localized chat showed an English sentence under a wrong
heading. Home is worse: its send path fails before a run exists, never
reaches that mapping at all, and prints `err.message` verbatim.

Classify the window separately, ahead of the hard-quota reading, and give
both surfaces the same localized copy: high demand, when it reopens, and
the reassurance that nothing was charged. The instant is rendered in the
reader's locale — the gateway reports UTC. Both surfaces read the signal
through one shared contracts module so they cannot drift, and degrade to
copy that does not promise a time when no instant is readable.

Other rate limits are untouched: a genuine session-limit exhaustion keeps
its hard_quota reading, and every other business failure on Home still
prints verbatim.

* fix(analytics): keep a campaign entry for the whole campaign

The attribution record expires after seven days, but this campaign runs
fourteen. A visitor who clicks the banner on day 1 and subscribes on day
11 arrives at checkout with no entry left to attribute the payment to,
so the campaign under-reports against the metric it is judged on --
活动归因付费人数 / 金额, both defined as payments carrying its
`campaign_id`.

Extended only for entries that carry a `campaignId`. Raising the window
globally would move the attribution口径 for every other entry point and
the dashboards already built on it; the campaign is the thing that needs
a longer memory, so it is the thing that gets one. Past fourteen days a
campaign entry expires like any other -- the longer window is scoped to
the campaign, not an open-ended exemption.

* fix(campaign): carry the new campaign id through ingest and e2e fixtures

Two places still recognised only the finished free week's id, both found
in review.

The daemon's AMR entry ingest allowlist is fail-closed: an unrecognised
`campaignId` voids the WHOLE entry rather than just its campaign field.
With only `deepseek_v4_flash` listed, every entry this campaign produces
would have been dropped at ingest -- and its success metrics are defined
as payments carrying its `campaign_id`, so it would have reported zero
conversions while converting normally. Both ids are listed now, since
entries minted during the free week can still arrive inside their
attribution window.

The Playwright suite pre-seeds a dismissal so the campaign modal never
opens over a functional spec. That seeding was keyed on the old campaign
id, so the modal would have covered Home in every UI spec. `home-hero-
rail` also clears storage and re-seeded its own hardcoded copy of the
key; it now reapplies the shared map instead, so a future campaign
cannot be dismissed in one place and left interrupting specs in the
other.

* fix(campaign): land the workbench badge on the console, like the modal

The badge opened a hardcoded `https://open-design.ai/zh/pricing/` while
the modal's CTA opened the console's plan surface scoped to the caller's
workspace. Both are in-product entry points for a signed-in user, so one
funnel was being split across two destinations -- and only one of them
lets the user actually subscribe.

The hardcoded link also pinned `/zh/`, so every non-Chinese user landed
on a Chinese page from a badge that renders in all 19 locales.

Now shares the modal's destination, including the workspace scope, so
the console can resolve the right upgrade surface for that workspace.
Verified against a real browser: the badge opens
`/amr/dashboard?workspaceId=…&billing=plan` carrying entry id, source,
campaign id, conversion source and device id.
2026-08-13 16:37:16 +00:00
698a17aac2 feat(web): refresh home and preview workspace controls (#6692)
* feat(web): home type-fan picker, floating account cluster, docked tab dropdown

Home hero: the 12 create-scenario types render as a fanned card carousel
above the composer (flat elliptical arc, apex card = selected, arrows /
card click switch along the arc; selected card scales about its own
center on an inner body so the outer transform stays a pure arc move).

Entry chrome: the account module leaves the nav-rail bottom and rides a
fixed top-right cluster next to the DeepSeek campaign badge — credits as
its own pill, avatar-only circular trigger, hover menu opens downward
(right-aligned, width-capped). The cluster is app-region no-drag so the
menu stays reachable in the desktop shell.

Project route: the docked workspace tab strip becomes one white dropdown
spanning the chat column (MRU order, most recent first), and the chat
collapse control lifts out of the chat card header to sit left of it.

Design files: the category tab bar drops its near-opaque sticky backdrop
strip and scrolls with content.

* feat(web): polish top-right capsule and dock controls, seed default deck chip

- Home composer seeds 幻灯片 (deck) as the default creation type on a
  fresh mount — same silent deferred-apply path as a user pick, skipped
  whenever a persisted chip draft owns the composer.
- Type fan: tighter card spacing (arc pivot 3700px → 3000px, ~128px
  between neighbours) so the hand-of-cards overlap reads again.
- Top-right chrome: credits + avatar merge into ONE capsule (pill
  material on the shared wrapper, chrome-free 24px segments inside,
  no hover fill on the credits segment).
- Project dock: the chat-collapse control gains a white seat matching
  the tab dropdown, and the dropdown drops its border (shadow only).

* test(web): align refreshed home chrome acceptance coverage

- Read account badges and member labels from the floating account menu.
- Match the default deck route, non-sticky Design Files tabs, and lifted chat control.
- Navigate projects through the visible docked Home control instead of hidden state nodes.
- Cover the affected Web suite and focused UI P0 restoration flows.

* fix(web): lock submit until default routing is ready

- Keep fresh-home sends disabled while the plugin catalog and default deck binding settle.
- Let explicit plugin handoffs supersede the default without leaving Send locked.
- Add a red-spec that holds the catalog response and verifies the routing barrier.

* test(e2e): await seeded type before picker actions

- Wait for the fresh-home deck binding before opening the creation-type menu.
- Switch to Prototype instead of clicking the already-active deck row.
- Preserve clear-selection coverage without racing menu reconciliation.

* fix(web): keep type menu open during internal scroll

- Listen for scroll only on the trigger ancestor chain and the viewport.
- Let the portaled creation-type list scroll without dismissing itself.
- Add coverage that distinguishes internal menu scroll from anchor-moving scroll.

* test(web): await routed submit readiness in legacy flows

- Wait for the Home submit control to become enabled before exercising the AMR workspace race path.
- Wait for refreshed workspace metadata and default routing to settle before submitting a community template.
- Keep the assertions aligned with the production guard that blocks submits until a deterministic default route exists.

* feat(web): replace the home type fan with a capsule pill row

The 12 create-scenario types render as ONE line of capsule pills exactly
as wide as the composer below: pills that fit render inline (8px gaps
throughout), 视频 stays pinned before the 全部 trigger, and the rest fold
into the 全部 popover. Fit is measured against an invisible probe row so
visibility never feeds back into its own measurement. 幻灯片 leads the
order (default-selected type); 网站复刻 trails so it folds first.

The fanned card carousel this replaces is fully removed (component +
styles) — selection still flows through the same onPick scenario path.

* feat(web): drop the clear-type control, cap the type menu at 6 rows

Clearing the creation type is gone (per product): the composer pill loses
its inline x (the chevron now stays in every state) and the menu loses its
leading Clear row, so a type is only ever swapped for another. TemplatePicker
no longer takes onClear.

The type menu shows exactly 6 rows and scrolls for the rest (6 x 44px row +
5 x 2px gap + 12px padding).

Specs that drove the removed control are rewritten around swapping instead:
the e2e clearHomeTemplate helper is deleted, pickHomeTemplate now observes
the pill's has-selection state, and the three clear-specific specs assert the
affordance is absent.

* feat(web): lift the GitHub star badge into the top-right cluster

The star count leaves the account menu's social row and becomes its own
chip between the campaign badge and the account capsule, sharing their
pill material. The menu's social row keeps Discord / X / mail.

* chore(web): clean up stacked EOF whitespace

- Remove the trailing blank line introduced in the home hero stylesheet.
- Keep the shared Playwright home helper free of the same EOF artifact.

* fix(web): polish preview workspace controls

* fix(web): stabilize floating comment interactions

- Treat a configured comment portal as an overlay from the first render and
  pre-mount its empty host, preventing the preview from briefly entering the
  320px local side-dock layout before the floating card appears.
- Route the floating card's close button and Escape key through one cleanup
  path, restore focus to the originating toolbar trigger, and clear board,
  create, and composer state so the card reopens with one click.
- Add red-spec coverage for the transient side-dock class, single-action
  close/reopen behavior, and focus restoration; the tests fail on the PR base
  and pass with this fix.
- Align the signed-out balance action with pill-radius controls, remove the
  redundant portal label, delete obsolete radial/reset/search/card picker CSS,
  and clean the two stacked EOF whitespace issues.
- Preserve the intended steady-state overlay behavior and validate the change
  with 389 focused tests, workspace typecheck, guard, diff checks, and Electron
  frame/focus sampling.

* test(e2e): align shared project title assertion with focus mode

- Wait for the file workspace to become visible after first-open materialization.
- Assert the canonical project-title state without requiring the intentionally hidden focus-mode dock to be visible.
- Preserve stale-title rejection and the visible catalog-title check after returning to All Projects.
- Validate with three focused repetitions, guard, workspace typecheck, and the entry-settings CI lane.

* fix(web): stabilize responsive home controls

- Recompute the Home type-pill split when localized labels or intrinsic pill widths change without a container resize.
- Observe the measured pill children and tail so late font layout cannot leave the overflow decision stale.
- Restore composer errors to the reviewed in-flow presentation instead of carrying the unrelated fixed top-chrome toast.
- Add focused coverage for locale-style rerenders and post-render width settling.

* test(e2e): await default home routing in attachment flow

- Wait for the visible deck binding before checking the Home attachment submit lifecycle.
- Use the shared long timeout so grouped CI does not race catalogue readiness.
- Preserve the production submit gate and the attachment add/remove assertions.

* feat(web): add a view-all-comments entry to the comment popover

* feat(web): cap the docked tab dropdown at six scrollable rows

* fix(web): preserve comment and tab menu interaction semantics

- Return focus to the still-open View all comments control when the floating comment panel closes.
- Include border-box borders in the six-row tab menu height so the last row is not clipped.
- Add focused regression coverage for comment focus restoration and menu sizing.

* test(web): await default routing in AMR home flows

- Wait for the cold Home composer to finish binding its default deck route before clicking Send.
- Preserve the existing AMR balance retry and auth-expiry assertions after the new readiness gate.

* fix(web): preserve explicit Home submit routes

* fix(web): defer restored Home plugin apply

* fix(web): close workspace chrome review gaps

- Clamp the Home template menu to six rows and the visible viewport, flipping it above the trigger when needed.
- Reset docked tab-menu state when its route-owned portal disappears and preserve editor-owned keyboard shortcuts.
- Scope the project header account identity, balance, and billing destination to the project workspace while retaining the ambient fallback for unbound local projects.
- Cover tall and short viewports, dock remounts, composer focus, and ambient/project workspace divergence.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Amy <1184569493@qq.com>
2026-08-13 11:16:44 +00:00
open-design-crew[bot]GitHubxiaoche-hubmrcfpsopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
4908baa4f4 feat(design-systems): ship structured runtime workflow (#6805)
* feat(design-systems): add structured runtime contract (#6456)

* feat(design-systems): add structured runtime contract

* chore: retrigger CI for PR 6456

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>

* feat(design-systems): resolve structured runtime intents

* fix(design-systems): preserve workspace runtime scope

* test(design-systems): cover scoped prompt runtime

* fix(design-systems): use one component authority

* fix(design-systems): preserve run workspace authority

* fix(design-systems): honor runtime rollback gate

* fix(design-systems): pin run binding authority

* fix(runs): reject client-supplied scopes

* fix(design-systems): keep user package assets scoped

* feat(design-systems): validate generated UI adherence

* test(e2e): assert durable reload authority state

* fix(design-systems): reject non-executable adherence evidence

* fix: harden design system adherence validation

* fix(design-systems): validate JSX selectors structurally

* fix(design-systems): close adherence input gaps

* chore(nix): refresh pnpm dependency hashes

* chore(ci): retrigger design system validation

* fix(daemon): bound design system artifact reads

* fix(design-systems): bridge Codex runtime tools

* docs(codex): clarify shell environment policy

---------

Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-08-13 09:02:20 +00:00
CaprikaandGitHub e654592ba6 fix(daemon): point users at the Settings section that actually exists (#6831)
* fix(daemon): point users at the Settings section that actually exists

When image generation failed for a missing credential, the reply told
the user to configure it in "Settings → Media". No such nav item exists —
the section is "Media providers" ("媒体生成提供商" in zh-CN) — so users
following the instruction found nothing.

The cause is that Settings destinations were spelled independently
wherever a producer happened to need one: 29 provider errors in
apps/daemon/src/media/index.ts said a bare unnamed "Settings" (one said
"Settings -> Media Providers"), and the od-media-generation skill said
"Settings → Media". None of them were tied to the Settings dialog, so a
rename could not reach them.

packages/contracts/src/settings-nav.ts is now the single place a Settings
destination is spelled, and every producer reads it: the media provider
errors, the ElevenLabs voice-options prompt (daemon + contracts), and the
external-MCP auth guidance. e2e/tests/settings-nav-copy.test.ts holds the
constants against the labels apps/web actually renders and fails the
build if a producer re-inlines its own guess.

Swept the same drift class while here: both hatch-pet SKILL.md copies
pointed at "Settings → Pets", but #5517 folded the pet picker into
General, so the real path is "Settings → General → Pets".

Verified the gate red on the pre-fix tree (3 failing assertions) and
green after. Adjacent, not fixed: these strings are English and the
agent translates them ad hoc, so a localized client can still render a
section name that does not match its own UI — see the report.

Record: recvre8FrTE2Oa (V0.19.1 acceptance)

* fix(contracts): gate destinations on rendered nav, not the i18n dictionary

Review on #6831 caught that SETTINGS_EXTERNAL_MCP named a place users
cannot navigate to. `mcpClient` kept its Settings render block but lost
its sidebar nav item (SettingsDialog.tsx says so in its own comment), and
reconnecting an external MCP server now lives in the top-level
Integrations view. So the destination is not a Settings section at all.

The stale string predates this branch — it was hardcoded in
prompts/system.ts on main. But promoting it into a named constant made it
worse twice over: the docblock asserted "The Settings nav item where
external MCP servers are added/reconnected" in a canonical location, and
the new gate whitelisted it by construction, so the check built to catch
dead paths became the thing defending one.

The deeper defect was the gate's invariant. Comparing a constant to its
i18n key's value proves only that some dictionary entry matches — not
that anything renders it. `settings.externalMcpTitle` still resolves to
"External MCP" with no nav item behind it, which is exactly how the dead
path passed. The same hole covered SETTINGS_MEDIA_PROVIDERS.

Now the gate parses what the UI actually renders — `settings-nav-item`
buttons and INTEGRATION_TABS — and resolves those keys through the
dictionary, using it as a lookup rather than as the source of truth.
Verified by reintroducing "Settings → External MCP": the old gate passed
it, the new one fails with the rendered destinations listed.

Replaces SETTINGS_EXTERNAL_MCP with INTEGRATIONS_MCP_PATH
(`Integrations → MCP`) and adds a regression test pinning that a
dictionary-only label is rejected.
2026-08-13 08:42:27 +00:00
CaprikaandGitHub 9120f3dab1 fix(web): name the reason when an invite target is already a member (#6830)
Inviting an address that is already an ACTIVE member of the workspace
showed the generic "invitation failed, try again later" copy, so the user
had no way to learn that retrying could never succeed.

The dialog already branches on B's allowlisted invite-create codes; the
gap was in the allowlist itself. B rejects that case with HTTP 409
`{"error":"invite_existing_member"}`, which was absent from
WORKSPACE_INVITE_CREATE_ERROR_ALIASES, so it degraded to the
transport-shaped `create_409` and fell through to the retry-later
fallback. Add the alias, and split the two 409 duplicate variants apart:
"already a team member" and "already has a pending invitation" call for
different next steps and no longer share one merged sentence.

The code -> copy mapping moves out of the component into
`collab/invite-error-copy.ts` as a pure code -> i18n-key function, so a
new B code has exactly one place to be taught and the mapping is
testable on its own.

Verified red on the alias-less baseline (4 failures across both specs)
and green after.

Record: recvrovm9Bcyy0 (V0.19.1 acceptance)
2026-08-13 08:18:53 +00:00
lefarcenandGitHub b55d443b44 fix(web): streamline Home submit preflight (#6756)
* fix(web): retry transient home balance preflight

* refactor(web): streamline home submit preflight

* fix(web): keep local submit independent from cloud identity

* fix(web): preserve local and legacy submit lanes

* fix(web): keep in-memory workspace witness valid

* fix(web): retain workspace preflight compatibility

* fix(web): require workspace witness for team plugin apply

* fix(plugins): apply exact local plugin source

* fix(web): invalidate legacy witness on identity change

* refactor: keep Home submit local-first

* test: align local-first creation coverage

* fix: preserve exact local plugin context

* fix: fence exact local plugin use

* fix: disable inexact legacy plugin fallback

* test: mock exact plugin apply in visuals

* fix: reject unsupported exact project source

* Revert "fix: reject unsupported exact project source"

This reverts commit 99e0cf23c1.

* Revert "test: mock exact plugin apply in visuals"

This reverts commit 33b36688c7.

* Revert "fix: disable inexact legacy plugin fallback"

This reverts commit d1b4d9bcaa.

* Revert "fix: fence exact local plugin use"

This reverts commit aebaf6a20b.

* Revert "fix: preserve exact local plugin context"

This reverts commit aa6ab67631.

* Revert "fix(plugins): apply exact local plugin source"

This reverts commit e776a7d079.

* fix: reconcile local submit selections

* fix: preserve cold cloud submit handoff

* test: seed active team design-system binding

* fix(plugins): preserve exact local source semantics

* test(web): align Home failure copy after main merge

* fix(home): preserve staged resource catalog scope
2026-08-13 06:33:29 +00:00
lefarcenandGitHub 85d2e4893c fix(amr): recover expired cloud sessions (#6786)
* fix(amr): recover expired cloud sessions

* fix(diagnostics): redact dynamic API routes

* fix(daemon): honor settings in AMR diagnostics

* fix(daemon): align workspace auth revisions

* fix(web): gate expired cloud sessions on sign-in

* fix(web): scope cloud sign-in gate to cloud execution

* test(web): align cloud auth fixture

* fix(web): recover transient run authority failures

* test(daemon): expect retryable authority detail

* test(e2e): align BYOK auth boundary

* test(e2e): align expired cloud recovery flows
2026-08-13 02:22:39 +00:00