After the user answers the one clarification round, the agent's next turn
carries a complete Full Plan plus a Runtime State. On 0.22.1-prerelease.9
(OPEND-2954) that state said `inputStage: "request"` — the value every
Runtime State example in the protocol reference shows, and the one field the
clarification continuation never named — and the coordinator refused the turn
on that field alone. The task settled terminal-`blocked`, a plan that passed
every other gate never reached production, and no repair path could anchor on
it because the block was schema-valid and the mismatch is not a parser issue.
The stage is host-owned truth: the daemon issued it, wrote it into the
continuation wrapper, and holds it on the task, so a disagreeing declaration
carries no authority — the same footing as the execution mode a clarification
turn predicts, which the parser already discards. `adoptHostInputStage` now
gives the state the host's stage before validation whenever the corrected
state is a valid declaration for that stage, and records the decision as a
normalization rather than a reason code so an agent-declared `blocked` keeps
its own attribution. Where the field does carry meaning — a second
`clarification_required`, a `plan_ready` at production — the corrected state
fails the contract, the agent's value stands, and the turn is refused exactly
as before. The serialization-repair anchor gets the same treatment so a fenced
plan on a clarification turn can still enter its one repair.
The clarification continuation now also says which stage it runs at, mirroring
the production continuation's `inputStage production`, so the agent has one
sentence to follow instead of three examples that all say `request`.
Specs: a clarification turn declaring `request` / `plan_ready` with a valid
plan reaches `plan_ready` (red on main: blocked with
`od_next_protocol_stage_mismatch`); an agent-declared block with the wrong
stage keeps `od_next_agent_declared_block`; a second question round is still
refused; a fenced plan with the wrong stage still anchors a repair; the
continuation text names `inputStage clarification`.
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
* fix(daemon): keep proxy Basic credentials off the media wait tool-token lane
The media task wait route selected the run-scoped tool-token lane on the
mere presence of an Authorization header. Tool tokens are only ever
parsed out of a Bearer header (bearerTokenFromRequest), so a reverse
proxy that authenticates browsers itself and forwards its own
`Authorization: Basic ...` could never satisfy that lane: every proxied
poll of POST /api/media/tasks/:id/wait failed 401 TOOL_TOKEN_MISSING,
so image and video generation never reported completion.
Gate the lane on the Bearer scheme. The existing intent is preserved —
once a Bearer caller does choose the lane, invalid, expired or
under-scoped credentials still must not downgrade to project
authorization; this only changes what counts as choosing it.
The other two authorizeToolRequest callers in this file,
/api/tools/media/scaffold and /api/tools/media/generate, are agent-only
endpoints that unconditionally require a tool token and are unchanged.
Same root cause as #7819, which covers the export and archive routes in
apps/daemon/src/import-export-routes.ts.
* fix(daemon): fail closed on a malformed Bearer credential
Review catch: `authorizationHeader.trim()` reduces `Authorization: Bearer `
to `Bearer`, which the `/^Bearer\s+/i` predicate rejected. That sent a
malformed Bearer credential to `authorizeProjectRequest` instead of
`authorizeToolRequest`, so a caller reaching for the tool-token lane could
reach the browser project-authority lane — before this branch, any
Authorization header produced TOOL_TOKEN_MISSING.
Classify the scheme independently of whether a token follows it, so both
`Bearer` and `Bearer <invalid>` stay on the tool-token lane and fail closed,
while Basic credentials still reach project authorization.
Regression coverage added for `Bearer ` and a bare `Bearer`; both return a
full task snapshot (HTTP 200) with the previous predicate and 401 with this
one.
* fix(daemon): keep proxy Basic credentials off the export tool-token lane
authorizeExportRead treated any Authorization header that was not the
daemon API token as a run-scoped agent tool-token call. Tool tokens are
only ever parsed out of a Bearer header (bearerTokenFromRequest), so a
reverse proxy that authenticates browsers itself and forwards its own
`Authorization: Basic ...` (Coolify native basic auth, Traefik basicAuth
without header stripping) could never satisfy that lane: every browser
export and archive request failed 401 TOOL_TOKEN_MISSING.
Gate the tool lane on the Bearer scheme so foreign Basic credentials fall
through to project authorization like a header-free browser request.
Foreign Bearer tokens still fail closed in the registry.
Covers export/{html,pptx,pdf-image,image}, the generic export route,
archive, archive/batch and the inline-HTML route, which all share the
helper.
Root-cause analysis and the Bearer-gate approach are @airano-ir's, from
the #7819 report.
Fixes#7819
Co-Authored-By: airano <244096521+airano-ir@users.noreply.github.com>
* fix(daemon): fail closed on a malformed Bearer credential
Carries the review finding from #8006 across to the export lane, which had
the same predicate. `authorizationHeader.trim()` reduces `Authorization:
Bearer ` to `Bearer`, which `/^Bearer\s+/i` rejected, downgrading a
malformed Bearer credential to browser project authority instead of failing
closed with TOOL_TOKEN_MISSING.
Classify the scheme independently of whether a token follows it. Regression
coverage added for `Bearer ` and a bare `Bearer`.
---------
Co-authored-by: airano <244096521+airano-ir@users.noreply.github.com>
* test(web): reproduce question title wrapping
* fix(web): keep question titles on one line
* fix(chat): stabilize question metadata and verify the rendered header
* fix(daemon): fail blocked OD Next runs before finalization
* test: align run fixtures with OD Next terminal status
Keep generic session and smoke fixtures on ordinary runs, and assert the blocked verdict in the strategy timing fixture.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* test(daemon): isolate Codex resume and retry fixtures from OD Next
Select ordinary-run mode for plain-reply session and recovery fixtures, restoring the rollout override after each test.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* fix(daemon,web): stop a refused turn reporting a delivered deck as failed
One predicate answered two different questions, and the strict answer is the
wrong answer to the loose one.
`validateRunDeliverable` asks "did THIS run write the canonical entry". That is
the right gate for accepting an Agent's completion claim — a turn must not pass
by pointing at a file an earlier turn wrote. It is also `false` for the most
ordinary shape of an OD Next refusal: the user types 「继续」, the agent
re-checks work an earlier turn already finished, correctly rewrites nothing,
and the turn is refused over its machine block.
Both safety nets read that one answer, so both went down together. The
coordinator's completion inference declined, and `deliveredDespiteBlock` in the
web provider — the carve-out that exists to keep a delivered turn out of the
failure branch — could never fire. A field report shows the result: a red
「回复已收到,但没能记录下来」card under a finished 16-page deck the user could
see rendered in the preview pane beside it. The physical run had exited 0 and
the 2.27 MB file was on disk.
`validateProjectDeliverable` is the second question: does the user have it,
right now. Same entry resolution, same kind contract, same readability check,
minus the run-scoped gates. `GET /api/runs/:id` resolves it only for a terminal
run whose strategy task settled blocked — the one case a client has to
distinguish — and logs the answer, because withholding a failure the user
cannot act on is right and withholding it invisibly is not.
The verdict does not move: the task still blocks, the strategy contract still
refuses to complete on an unproven claim, and a turn that delivered nothing
still fails with its card. What changes is that a bookkeeping failure no longer
tells a user they lost work that is sitting in their project.
This is also what `docs/design/run-errors/error-ux-design.md` §3 already ruled
for the case: 「文件生成了但进程报错:算成功」, with the stated reason that doing
otherwise "把一堆无害报错变成失败潮".
Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「does not become a failure when the project holds the deliverable」 — red on
main, green here.
* fix(daemon,web): name the gate that refused a turn, not the parser that noticed
The protocol parser raises `od_next_protocol_runtime_state_missing` before
`validateAcceptedTurn` — the function that owns the real gates — is ever
reached. So every block-less turn was filed under the parser's own code, and
`reasonCodes[0]` is what the failure card, the diagnostics export and the
analytics bucket all read.
The effect on a user who got nothing this round: they were told a marker was
missing. True of the reply, useless to them, and not the reason the turn could
not succeed — the identical failure with a DECLARED block reported
`od_next_canonical_deliverable_invalid`, the host fact they can act on. Two
names for one failure, and the less useful one was the one that shipped.
`reattributeUndeclaredTurn` already existed for exactly this, scoped to one
cell (a clarification turn that asked again). Extend it: when a block-less turn
was SHAPED like a completion the host may infer and the inference still
declined, the evidence is what refused — both helpers gate on the shape, which
just held, and the evidence. Lead with it. The parser's code stays behind it,
because `blocked_reason_codes_json` is the durable record of what the turn
actually wrote, and a triage that cannot tell "declared badly" from "declared
nothing" has lost the one distinction that decides whether a serialization
repair was ever possible.
ATTRIBUTION ONLY — no verdict moves. The two existing specs that pinned the old
name still assert the verdict they own and now assert the new attribution; the
turns they describe block exactly as before.
`od_next_canonical_deliverable_invalid` gets the card it now needs. It is
deliberately not the Runtime State copy: those say "your reply went missing",
this says "nothing came out this round", and only the second is true when the
project is empty. Retry earns its place here — the deliverable is missing
because this turn wrote nothing, and a re-run is the thing that can write it.
⚠️ The copy is engineering's, like the rows around it. `error-ux-design.md` has
no cell for it; S23 (跑完没生成文件) is the nearest and is listed there as
invisible in telemetry. Product should rewrite the wording, not the routing.
Red spec: apps/daemon/tests/strategies/od-next/coordinator.test.ts
「names the evidence that refused an undeclared completion, not the parser」 and
「gives the undeclared and declared shapes of one failure the same name」 — both
red on main, green here.
* feat(daemon): make an OD Next block countable instead of invisible
`run_finished.result` is derived from the physical run status
(`runResultFromStatus`: `succeeded → success`), and a refused OD Next turn
normally exits 0 with a complete reply on screen. So this entire class of
failure reported `result: 'success'` while the user was looking at a red card,
and nothing queryable disagreed — the reason codes lived only in the task
store's `blocked_reason_codes_json` and in a client-side error object.
`error-ux-design.md` §《线上频次与优先级》 already flags the shape of this
problem for S23/S24: 「被算成了成功 —— 看不到是坏事:线上到底有多少,我们不知
道」. OD Next made it worse than invisible by pairing the silent metric with a
visible failure card.
Carrying `reasonCodes[0]` — the same value the card keys on — makes the class
countable without changing what `result` means. Read off the run's own terminal
projection, the object the SSE `end` payload and the failure card were both
built from, so the three cannot drift. Omitted for every run whose strategy
task did not block, which is the common case.
This is the half that makes the suppression in the preceding commits safe to
ship: a failure the user is no longer shown is one we can still count.
* fix(web): do not go silent on a turn that produced no reply either
The preceding fix credits a deliverable an EARLIER turn wrote, which is right
for the case it was written for — the agent re-checks finished work and
correctly rewrites nothing — and wrong for the mirror case in #7564: a blank OD
Next response into a project that already holds a prototype. There the file is
real and the user still got nothing, so withholding the card swaps one wrong
answer for the other.
Require the turn to have actually said something before the looser field can
suppress a failure. The accumulated assistant text is already on hand in the
stream consumer, so this costs nothing and needs no new daemon field.
The stricter field keeps its existing behaviour untouched: an artifact THIS run
produced is delivery whether or not the agent narrated it.
Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「still fails when the turn produced no reply at all」 — verified red with the
guard removed, green with it.
* refactor(contracts): make the OD Next block dimension a tested pure mapper
Observability that nothing exercises is observability that rots. The derivation
was inline in the run lifecycle, where the only way to reach it is to boot the
whole analytics flow — so it would have failed silently to "field absent",
which is indistinguishable from "no run ever blocked".
Move it next to `harnessAnalyticsFromRolloutDecision`, which answers the
neighbouring question about the same run and already has a suite. The edges
that matter are the ones a wrong answer would quietly mislabel: a task that has
not settled, one that settled on something other than `blocked`, and a blocked
one an older daemon projected without a reason.
---------
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
The watcher treated "I once saw this run completed" as permanent and stopped
polling the lane. GitHub re-runs a workflow IN PLACE — run_attempt increments,
status returns to in_progress, conclusion is rewritten, all under the same run
id — so the card froze on the first attempt's verdict for the rest of the watch.
Both dispatched lanes had it. Each now re-reads its run every cycle and refreshes
the paginated job list only when the run's own status/conclusion/run_attempt/
updated_at say it moved, so watching for a re-run costs no call the lane was not
already making.
Claude-Session: https://claude.ai/code/session_01PbTMU3bKHcu6cmJYy42eF3
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three follow-up suggestions under a completed turn are written by the
model, not picked from i18n files, so the only thing that decides their
language is the prompt. That prompt carried one soft rule — "write them in
the language the user is speaking" — printed directly under three English
examples, and nothing about the run's actual locale.
The locale rule lived only in `# UI locale override`, a section of the
cache-stable prompt head that `server.ts` deliberately drops on resume turns
to protect the upstream prompt cache. The keyed follow-up block is re-sent
every turn. So the rule was absent exactly on the turns that carry the
marker, and a zh-CN run closed with English suggestions — one of them a
near-copy of the prompt's own example.
Thread the run locale into `renderChatTurnHostProtocolInstructions` so the
per-turn block states its own language requirement, including the existing
brand/code/technical-identifier exception so `localStorage` and `CTA` still
survive. An English or absent locale renders byte-for-byte what it did
before. Also collapses the third copy of the zh-CN/zh-TW name mapping into
`prompts/ui-locale.ts`.
Fixes OPEND-2765
* fix(chat): render cards inside execution records
Render supported od-card payloads inside execution records with the existing
card components, instance scopes, and browser-assistance callback. Preserve
Markdown code examples and isolate card payloads from later Markdown parsing.
* test(web): await writable scope before deleting a conversation
* fix(web): buffer streamed card opener prefixes
* fix(web): remove undesigned ChatPanel cards (OPEND-2971)
Remove task-brief and rule-proposal presentation and their exclusive UI,
styles, and translations following the explicit product decision.
Preserve quoted card examples, neighboring prose, and the three retained
card kinds, including browser-assist callbacks.
* fix(chat): stop generating undesigned brief and rule cards
* test(web): use supported role query options for removed cards
* fix(chat): hide retired brand browser assistance cards
* fix(web): hide undesigned assistant message panels
* test(web): specify the brand project kind in hidden UI fixture
* style(web): remove trailing blank line after panel styles
* fix(web): hide verification scorecards in ChatPanel
* test(chat): align retired UI expectations with approved hiding
* fix(chat): keep card payloads opaque to question form parsing
* fix(web): preserve card parsing across replay metadata
Keep marker masking aligned with physical run boundaries, and replace repeated
Markdown tail scans with a forward indexed walk. Preserve actual forms and raw
code examples with replay and paragraph-boundary regression coverage.
* fix(web): keep form field syntax inside its payload
Recognize complete forms through the existing parser before scanning their
fields for card or code markers. Preserve sibling form actions and keep outer
card/code ownership with callback-based parsing that avoids an import cycle.
* fix(chat): share question form protocol context with host detection
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): wait for pending clarification before continuing todos
* fix(chat): preserve recovery for truncated clarification forms
---------
Co-authored-by: lefarcen <935902669@qq.com>
* refactor(release): extract the patch-cut pre-flight out of inline shell
Faithful port, no behavior change: resolve-patch-cut.ts computes the same
version and the same X.Y.0 gate target the shell did, and runs the same
gh release lookup. Moving it out of YAML is what makes the gate decision
testable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(release): pin the patch cut on the previous patch, not the minor base
Red against the current guard: with release/v0.22.2 cut and unshipped, the
pre-flight gates open-design-v0.22.0, finds it published, and cuts v0.22.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): gate the Thursday patch cut on the previous patch
The guard checked the minor base X.Y.0, so it only ever guarded a line's
first patch. Once X.Y.0 shipped, X.Y.2, X.Y.3, ... all passed no matter how
many unshipped releases had stacked up beneath them.
Gate on the release the cut actually stacks on — the highest release/vX.Y.Z
below it — which is the same target cut-release.yml uses for the Tuesday
minor, and still gives the answer the old minor-base comment wanted (cutting
0.15.1 checks open-design-v0.15.0, not the older 0.14.0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): surface patch gate lookup failures
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(chat): restore persisted strategy task blocked fields in conversation history
* fix(chat): scope history task projection to its conversation
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): recover transient transcript loads within a bounded window
* fix(chat): retry transient transcript responses without a hint
* test(web): await writable scope before deleting a conversation
---------
Co-authored-by: lefarcen <935902669@qq.com>
Use a fake clock to verify the existing 10ms deadline and the final readiness
snapshot after multiple inspections. Cover a first inspection that consumes
the whole budget, where a single observation is valid.
Keep the readiness helper and application behavior unchanged.
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): keep each folded run's own clock
A logical OD Next task runs as several physical Runs, and reopening a
conversation folds them into one turn. The fold keeps one message row, so
only Run 0's createdAt and Run N's endedAt survive and every Run boundary
in between is discarded. The renderer is then left with one clock for N
Runs, which shows up as two separate reports:
- a Run's shell with no timestamped events of its own (clarification
Runs typically have none, and the plain-stream agent family never
emits any) reaches neither the "opened this round" nor the "closed
this round" fallback, so its head is a bare "Done" with no duration;
- a thinking gap, inferred from "last timestamped event until the next
one", runs straight across the Run boundary and charges earlier Runs'
wall-clock time - including the minutes the user spent answering a
question form - to the current Run's Thoughts row, which then reports
more than the card head containing it.
Both are reload-only: strategyTaskRunIndex is served by the history
endpoint alone, so the same conversation renders correctly while live and
loses the numbers once reopened.
Restore the lost boundary rather than adjust either number. The fold
stamps each Run's own span onto the done_key it already emits, and the
renderer scopes the shell clock and the thinking gap to that Run. On the
recorded three-Run conversation the reloaded turn now reports 1m 20s /
2m 39s / 4m 42s and a 2m 57s Thoughts row - identical, to the millisecond,
to what the same Runs report live.
Also make a card's span cover the thinking row it credits, so the same
invariant holds for a second card opened mid-Run after a done marker.
OPEND-2823, OPEND-2824
* fix(chat): stop a card borrowing another run's clock
Review of #7921 caught the other half of the same defect. shellElapsed
still seeded from/to from the turn-global firstStartedAt/lastEndedAt when
a shell had no shellSpan of its own, then unioned those leftovers with the
new per-run bounds. A folded successor run that emits only status and text
- the typical clarification run, and every plain-stream agent - has no
shellSpan, so its card picked up the preceding run's tool timestamps and
reported that run's time as its own.
So the first commit fixed "a middle run shows no number" but left "the
number belongs to another run", behind a different trigger.
Drop the turn-global seed instead of clearing those clocks in closeRun().
firstStartedAt/lastEndedAt are the min/max of every timestamp in the turn,
so when this shell has no span of its own any value there was necessarily
stamped by a different shell: the fallback is, by construction, always
borrowing another card's clock and can never once be this card's own time.
Clearing at the run boundary would also only cover the cross-run trigger -
the identical borrow happens inside a single run when a second card opens
after a done marker with no timestamped events, where closeRun() never
runs - and it would silently change thinkGapStart and shellQuiet, which
read the same two clocks.
On the review's shape a card that ran for 20s reported 5m 10s; it now
reports 20s, which is what the same run reports live.
OPEND-2823, OPEND-2824
* docs(contracts): clarify missing folded-run timing boundaries
* feat(web): make chat telemetry name the run it is talking about
`setChatCorrelation()` has had no production caller since #7518. Every
`client_chat_*` event therefore shipped with an empty correlation block, and
`client_chat_interaction_latency` — the one chat observability event with real
traffic (2,694 events / 30 days) — derives its `streaming` flag from
`chatCorrelation().run_id`. With no producer the flag was false on every event
ever sent, so the panel read "every chat stall happens while the app sits
idle": a self-consistent, wrong story, and the reason the metric was worse
than no metric.
Wire the four producers that already had their values in hand:
- run id + agent id, opened at every run start (`streamViaDaemon`, the next
physical run of a strategy-task chain, and `reattachDaemonRun` — the path a
page refresh takes back onto a run still in flight) and closed in the same
`finally` that settles the stuck-run watchdog. Named helpers, because the
pair is load-bearing: only an opener and `streaming` is stuck true forever;
only a closer and it is stuck false.
- conversation id + project id, once `listMessages` resolves.
- release channel, off `/api/version` — the daemon has always answered with
`channel` and the web read `version` and dropped the rest.
- replay session id, handed over from the posthog-js instance inside
`loaded()`. This is the only possible source: the ESM build never publishes
itself as `window.posthog`, so the global-lookup fallback returns undefined
forever while looking implemented.
No new events. This only makes the traffic already being sent tell the truth.
`model_id` stays absent — it is not in scope at either run-start site, and a
guess would be worse than the gap.
* feat(analytics): record which failure sentence the user actually read
`run_failed_toast` says a user was shown a failure card and names the daemon's
`error_code`. It cannot say what the user READ — those two facts are separated
by a mapping table, and `resolveRunErrorCardDescription` states in as many
words that the table can always be one row short. When it is, the card falls
back to a blank "the task failed" with no diagnosis. That is the worst
impression this surface produces, and it was the one we could not count.
Add two fields to the exposure:
- `message_key` — the i18n key of the mapped copy, or `generic_fallback` when
the table had no line for it. Always present, never omitted: the fallback
RATE is the number that matters, and a rate needs a denominator, so an
omitted key would drop the fallback impressions out of the very count meant
to measure them.
- `failure_category` — the daemon's own classification off the run's error
event, defaulting to the enum's existing `unknown` member rather than
vanishing, so this event stays joinable against `run_finished` and
`run_recovery_action`.
`message_key` is typed as `string`, not the web's `RunFailureMessageKey`
union: that union lives in apps/web and grows with every copy change, and
pinning it in contracts would make adding a sentence a contracts change.
* feat(web): attach the chat-health monitor to the chat log
`chat-health.ts` has answered four questions since #7518 — how long until the
user can read the conversation, is the DOM growing without bound, who is about
to OOM, how janky is the UI while a run streams — and nothing but its own unit
spec has ever called it. All four tiles were empty in production.
Open a surface on the `.chat-log` element while the Chat tab is showing it,
and tear it down when it goes away. Two effect dependencies, each guarding a
real failure:
- `tab`, because the log is conditionally rendered and `logRef.current` is
null on the Comments tab — without it, coming back to Chat never reattaches.
- `activeConversationId`, because that div carries no conversation key, so
React reuses the very same DOM node across a switch. There is no node-level
signal that the surface should be replaced; the id is the only one. The
spec pins this by asserting node identity is unchanged AND that a second
surface opened anyway.
`markFirstPaint` lands together with `setStreamEventCount`, deliberately: a
first-paint duration on its own cannot be attributed, because a slow open is
either "many messages" or "one message with hundreds of tool events behind
it", and only the event count separates them. Rendered-row count uses the same
`:scope > *` definition `client_chat_dom_growth` already uses, so the two
events are comparable.
Run start / end feed the jank window from the daemon provider, which is where
the authoritative run lifecycle lives. Both no-op when no chat surface is
mounted.
Also extracts `isChatVirtualized()` so the renderer and the telemetry read the
same predicate. Two copies of `items.length > THRESHOLD` are identical today
and drift the first time the rule grows a second term — at which point the
telemetry would describe a render mode the renderer no longer uses, silently.
`markChatOpenIntent` is NOT wired here. Hanging it off the two conversation
handlers would only cover `conversation_switch`; `project_switch` has 10+
navigate sites in App.tsx and `cold_boot` passes through no handler at all.
The right home is `router.ts`, which is the single navigation entry point and
can derive the open kind from the route transition. That is its own change.
Until then `open_kind` honestly reads `remount` rather than a fabricated
`cold_boot`.
* fix(web): stop the wiring from reintroducing the bug it removes
Three review findings, all real, all the same family: an event that cannot
name what it measured.
1. `client_chat_stream_health` was going out with an empty `run_id`.
`chatSurfaceRunEnded()` does not merely bookkeep — it FLUSHES the jank
window, and the flush spreads `chatCorrelation()`. The terminal path
cleared the correlation one line earlier, so the one event this PR newly
enables shipped anonymous: the exact defect the PR exists to remove,
reintroduced by the fix for it.
One rule now covers both ends of a run: the chat-health call goes FIRST,
because it is the one that can emit; the correlation mutation goes second.
Applied symmetrically at the three run-start sites too — `runStarted()`
flushes any window a previous run left open (its terminal event never
arrived), and that flush belongs to the OLD run, so it has to happen before
the block is repointed. `trackRunTerminal` is unaffected either way; it
carries its own context and never reads this block.
2. A surface opened mid-run did not know a run was running. Surfaces are
recreated on every conversation switch and every return to the Chat tab,
but `chatSurfaceRunStarted` only ever reached the surface that existed when
the provider fired it. The new surface kept its window shut and dropped
every long task until the next run start — precisely the stretch a user is
watching, because they came back to the tab to see something generate.
`openChatSurface` now adopts the in-flight run. It reads it from the
correlation context rather than keeping a second flag: `chat-interaction.ts`
already derives its `streaming` breakdown the same way, and one source
cannot disagree with itself. No double counting — `runCount` and the window
clock are per-surface, and adoption only ever runs against the surface
constructed a line above it. The spec pins this with three long tasks split
across a mid-run swap and asserts they add up to three, not four.
3. `conversation_id` / `project_id` moved from ProjectView to the ChatPane
boundary. ChatPane has three hosts — ProjectView, DesignSystemFlow and
workspace/SideChatTab — feeding one set of observability modules and one
dashboard. Setting it in a single host left the other two emitting orphan
rows indistinguishable from genuinely un-correlated ones. Both already pass
the ids in as props. The effect is declared ahead of the surface-open effect
so the opening `conversation_open` sample is already stamped.
* fix(chat): make the run-failure card's retry tell the truth about the send gate
Three reports that all land on the same gate in ProjectView:
`currentConversationActionDisabled`, which every recovery handler consults
and no button was ever wired to.
OPEND-2821 — the retry button had no `disabled` at all, so it always looked
live. Clicking it reported a `manual_retry` recovery click and then
`handleRetry` returned. Split that boolean into a four-way reason
(read-only / messages-unavailable / billing-unresolved / conversation-busy),
disable the card's recovery actions when it is set, and say which one it is.
The split is a partition of the same predicate, proven exhaustively.
OPEND-2758 — `handleSend` paints the new turn before the preflight and before
POST /api/runs (OPEND-2614, deliberate), and that paint makes
`retryableAssistantMessage` return null, so the card vanished 1-2s before
anything was confirmed. Announce the pending retry, pin the card on the
message being retried, lock the button and label it "Retrying", and drop the
announcement only when the replacement message gets its run id -- or when the
send never started, which restores the original card and its actions.
OPEND-2719 — an exhausted wallet parked the send in the pending-send queue.
The queue means "this runs once capacity frees up"; an empty wallet never
frees up, so the message sat there forever. Stop queueing that one branch
(`signed_out` keeps its queue-and-resume path) and hand the draft back to the
composer through the existing `restore-draft` outcome. The report's other
half did not reproduce: the dialog and balance card were already re-firing on
every blocked send.
Note on telemetry: a blocked click no longer counts as a recovery click. It
never produced a run to join against, so those were unjoinable orphans; the
existing surface_view still records that the action was offered.
* fix(chat): claim the composer draft positively instead of by exclusion
Review on #7927: the no-queue path for an exhausted wallet used an
exclusion predicate — "not a retry and not a queue drain, therefore the
composer owns it". ProjectView has eleven other direct `handleSend`
callers (share to community, design-system feedback and review decisions,
continue remaining tasks, resume run, question-form answers, Home
auto-send, resend-failed-user-message, brand extraction and enrichment,
the auto-audit repair effect, Antigravity OAuth repair). None of them
holds its payload in the composer, and all of them were being swept onto
that path — which silently took away their queue. The question-form
answer is the worst case: it keeps the only copy of the answer and its
uploads, and releases the form only on a durable-queue acknowledgement.
Replace it with an explicit `composerOwnedDraft` marker that only
`handleComposerSend` sets. Everything else falls through to the queueing
path it had before this branch, unchanged. The marker is transport-only
and is stripped alongside `acceptDurableQueue` before queue persistence:
once a send is parked, the queue item owns the payload, not the composer.
A turn whose only deliverable failed reported "Completed" with a green
check. The daemon knew better: it logged `[media] {"event":"failed",
"run_id":…,"status":400}` and shipped the same run id to analytics, then
told the run nothing. Two halves of one invariant:
- routes/media.ts never wrote the failure back. `runId` was already in
`options.grant`; both failure paths (the provider rejection and the
synchronous dispatch throw) marked the task failed, emitted analytics,
logged, and returned. The run's verdict was left to the child's exit
code, and the agent exits 0 after apologising in prose.
- the turn-completion marker vetoed "unfinished" on nothing but "marker
matches the run's key, and some non-empty text follows it". In the
reported run that following text was the S22 apology we ask the model
to copy verbatim — so the apology suppressed the very signal it was
apologising for, over a TodoWrite snapshot that had a `cancelled` item.
`noteMediaTaskFailure` is the dual of `associateLateRunProducedFile`:
one hands the turn the bytes a 202 dispatch produced, the other hands it
the fact that it produced none. The terminal derivation reads that
host-recorded fact as its own term, outside the marker/strategy/todo
clause, so the model's account of its own turn cannot overrule a failure
the host watched happen. The evidence is never the agent's words: the
apology is product copy and may be rewritten at any time.
Scoped to the attempt (`prepareRestart` clears it) and gated on
`succeeded`, so a user-canceled turn and a turn holding no host
counter-evidence keep exactly the verdict they have today.
Both model lists cap their height and scroll — the compact home list at
six rows, the searchable popover at 280px — so a catalog longer than the
cap opened on its first row with the model actually running below the
fold. Users switching models frequently had to hunt for the selection the
list had already made (OPEND-2812).
The selection itself was never missing: the compact rows carry
`aria-checked` and the searchable options carry `aria-selected` /
`data-selected`, and the chip shows the same model. Only the scroll
position was wrong, so this adds no new state — `anchorSelectionInView`
reads the selection back out of the rendered list and scrolls those rows
into view once per open, with `block: 'nearest'` so a row already visible
does not move. Nothing marked selected means nothing scrolls.
The two-level company browse anchors both panes: the company rail now
mirrors its `has-selected` state onto `data-selected` so the vendor
holding the selection is brought into view alongside its model.
* feat(whats-new): announce OpenDesign Arena for 0.22.0
Product copy for the 0.22.0 post-update card. Bumping `id` to 0.22.0 is
what makes the card re-open for everyone who has already dismissed 0.21.0.
The zh-CN CTA points at /zh/llm-arena-for-design/, not the
/zh/llm-arena/llm-arena-for-design/ the copy originally carried — that
path 404s, the shorter one is live. Both destinations verified 200
before commit.
* feat(whats-new): update Arena numbers to DeepSeek V4.1 Flash
Second bullet revised by product: the model is V4.1 Flash, and it now
scores 98% of GPT-6 Astra at 1% of the cost (was 85% / 2%).
The zh-CN CTA in this revision matches the corrected path already in
this branch, confirming the earlier /zh/llm-arena/... was a typo.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
Reverses the "withhold the card until the first artifact publishes" rule.
The card is now posted on the watcher's first poll, while every platform is
still queued, and every later state edits that same message.
The reason the card was withheld — a "构建中…" message left standing forever
when a build dies — has since been answered by the machinery that grew around
the card: a dead lane renders its own terminal state, a release where nothing
shipped turns the card red, the watcher's 140-minute budget stamps the card
instead of abandoning it, and a watcher that never delivered draws the
fallback bot. Silence for the whole build window bought nothing.
`shouldPostFirstCard` conflated two predicates. One half was already
`anyPackagePublished`; the other is now `allPlatformsFailed`, and it moves
from gating the post to being the card's red verdict — the header's old
"some platform reported and none shipped" proxy is only equivalent while the
card does not exist before total failure, and with the card posted early it
reads 全部平台构建失败 the moment one lane fails, or red at t=0 because
`build_linux` is skipped on every run.
Each platform row now carries its build time (`用时 11m42s` once finished,
`已用 18m` while running) and the footer carries the whole round's clock, both
read from the `started_at` / `completed_at` already in the polled job list
plus the origin run's `created_at`. Live numbers are quantized to the minute
so a 30-second poll does not PATCH the card on every cycle.
* feat(daemon): make OD Next the default route, and keep an opt-out opted out
An installation that configured nothing ran the ordinary strategy route, so
shipping OD Next in a build never changed how a run behaved until someone
opened Settings and asked for it. That made the Labs switch the whole rollout:
the strategy could only reach the users who already knew to look for it.
`resolveRequestedMode` now resolves an unconfigured installation to `active`.
Every other admission condition already defaulted open — assignment bucket,
both content flags, the production approval flag — so this last default was the
only thing holding the route closed.
The one-line change is the small part. Flipping the default inverts which case
is load-bearing. While the strategy was opt-in, an installation that lost its
saved mode kept the behaviour it already had; now an installation that loses
its saved mode is switched back on. So the invariant this has to keep is that
an installation which opted out reads `off` through every later release, and
that rests on opting out being stored rather than erased: `off` is a value the
config carries, `applyConfigValue` drops the key only for a value it cannot
store, and `assertWritableControlValues` refuses those on the way in.
That last guard is the same code as before but now protects the opposite
direction. It used to stop a typo from masquerading as an opt-out; it now stops
a typo from revoking one. Its docblock, the field comments on both sides of the
contract, the `od strategy` help text, and the prompt-composition note all
described the opt-in world and would have read as the reverse of the truth.
Tests carry the semantics:
- a new assertion pins the opt-out invariant, and checks not just the value
read back but that the decision built from it actually evaluates to `off`;
- the route test now separates `default` from a saved `off`, which resolve to
opposite routes and no longer to the same one;
- the app-config acceptance case against a real server is rewritten end to end.
It was "nothing configured takes the ordinary route, and the same running
daemon takes OD Next once you opt in". It is now the opt-out direction, and
proves it from the agent invocation log rather than from the run record: the
first run's prompt is the strategy's, the run after `off` is saved is not.
Packaged installs have no second control — the packaged child environment
allowlist carries no `OD_NEXT_*` key — so the saved mode is the only thing
standing between an opted-out user and this default.
e2e is deliberately untouched here and follows separately. Its specs mostly
never set this mode, so prototype and deck cases now take the OD Next route.
Pinning the env var in the harness is not the fix: the env var outranks the
saved config by design, which would break the spec asserting that an
app-config write takes effect with `app_config` as its source.
* test(daemon): stop the panel-grammar spec failing on the clock
`thinking-stream-panel-grammar` asserted that the whole SSE body contains
neither `Critic` nor `8.1`, on the stated premise that both "只可能来自标记内部".
That premise does not hold for the second one. The body also carries the
daemon's own `start` envelope, and `toolTokenExpiresAt` is an ISO timestamp: a
run whose token expiry lands on a second ending in `8` with milliseconds
starting with `1` produces `…:48.161Z`, which contains `8.1`. Roughly one run in
a hundred, and it hit on CI — with a thinking stream the log shows was clean,
so the spec failed on the clock rather than on a leak.
The two attribute-fragment assertions now run against the concatenated
`thinking_delta` / `text_delta` text instead of the raw body. That is not a
weaker check: persistence consumes exactly those deltas, so it is what the
spec's own "SSE clean ≡ database clean" argument was always about, and the
envelope fields it drops were never a place a leak could surface. The tag regex
stays on the whole body, where an opening `<PANELIST` cannot appear by accident.
The two prose assertions move to the same extracted text, which is what keeps
the negative assertions honest: an extractor that selected the wrong event type
would return an empty string, and every `not.toContain` would pass against it.
* feat(daemon): remove the OD Next stop latch
A run that hit certain failures wrote a row to `strategy_rollout_controls`.
From the next request on, every task on that installation took the legacy
route. The row outranked the saved mode, survived restart, and the Labs switch
kept reading "on" while the notice underneath said the harness had been paused —
with no control to unpause it. The only way back was an operator running
`od strategy rollout reset`.
The code already argued against this. The docblock over
`rolloutStopSignalForBlockedContinuation` says only a failure of the machine
contract itself may raise a latch, because an agent turn that mis-populates a
machine block "is already fail-closed for that task ... and it must not silently
return every later request in the daemon to the legacy path." One signal had
already been retired for doing exactly that: `complex_child_unverified` took OD
Next away from Codex, Claude and OpenCode because a fourth runtime lacks a
child-lifecycle producer. This generalises those narrowings instead of waiting
for the next signal to earn its own.
It also stopped being survivable. While OD Next was opt-in, the latch could only
reach the few installations that had asked for it; with the default flipped it
reaches everyone, and the population exposed to an automatic, restart-surviving,
UI-unrecoverable disable is now the whole install base.
Deleting it changes nothing about how any single failure is handled. All three
live trigger points were additive actions taken *after* the existing handling:
`native_resume_failed` already blocked the continuation, sent
`AGENT_SESSION_RESUME_FAILED` and finished the run failed; the blocked-
continuation cases were already set to `outcome: 'blocked'` with their reason
codes persisted for attribution. What goes away is only the side effect on every
later request. A fourth trigger — the run-duration threshold — never fired at
all: `OD_NEXT_STRATEGY_MAX_RUN_DURATION_MS` has no default anywhere, so the
comparison ran against NaN.
What is removed: the table and its migration, the write path and its telemetry
module, the single read in the run route, the reset endpoint, the
`od strategy rollout reset` subcommand, the latched lock in the Labs switch and
its 19 locale strings, and five contract types. `readOdNextRolloutControlStatus`
stays but no longer takes a database — the effective mode is now a pure function
of the requested mode and the content flags, so two callers reading it at the
same moment cannot disagree. Status is still worth an endpoint because
`requestedModeSource` names the authority, and `default` and a saved `off`
resolve to opposite routes.
Installations currently holding a latch recover on their own: with nothing left
to read the row, their next run is admitted normally. No migration needed, and a
downgrade is safe — older code recreates the table, reads it empty, and treats
the installation as unlatched.
Tests carry the invariant rather than a list. The cases that pinned which
signals were allowed to latch are replaced by one that pins the shape: the
decision is a function of the policy and this run's facts and takes no argument
through which an earlier run could reach it, so there is no signal left to
enumerate and none can be added by accident. The real-server case now asserts
that status still reports the deciding authority while both the reset endpoint
and its CLI subcommand are gone.
* fix(daemon): never leave the harness switch stuck, whatever the config says
Field report from a packaged build of this branch: the Design Harness switch
read on, greyed out, with "detected a problem, generation is using the original
approach" underneath, and every task on the machine taking the legacy route.
Nothing in the UI could clear it.
The diagnostics name the cause exactly. One agent turn ended mid-block —
`Unclosed <open-design-plan-contract> block`, 2 characters of visible text — and
that raised `machine_contract_leak`, whose stop mode is `off`, not `observe`. A
truncated response took the whole install off OD Next; the next daemon start
logged `requestedMode: 'active'`, `effectiveMode: 'off'`,
`od_next_rollout_stop_latched`, which is the screenshot.
That is not a rare condition. A stream ending early is the most ordinary failure
an agent has, and flipping the default hands it the entire install base.
Three changes, one property: the switch a user can see is a switch a user can
use.
1. The stop latch is removed (previously PR #7879, folded in here). A build that
defaults on and still ships an automatic, restart-surviving, UI-unrecoverable
disable is not shippable as two PRs — the window between them is exactly the
build that was reported. Everything the latch did on top of the existing
per-task handling is gone; the per-task handling is untouched.
2. An unreadable saved config no longer reads as an unconfigured one. The read
path kept two states apart where it needed three: no file at all, a readable
choice, and a config that exists but cannot be believed — malformed JSON, a
non-object body, a mode this build does not recognise. The third used to
collapse into the first harmlessly, because unconfigured meant `off` and so
did an opt-out. Now unconfigured means `active`, so the same collapse turns
"we cannot read your choice" into "you chose OD Next", against the
installations most likely to have chosen the opposite. It resolves to `off`
instead — the only answer that cannot override a real decision, and the
recoverable one, since a user who never opted out sees the switch off and can
turn it back on. `assertWritableControlValues` was never enough here: it
guards writes through the API and can say nothing about a file that arrived
bad, was hand-edited, or was written by another version.
3. A failed status read is retried from the switch instead of being permanent.
It rendered the row locked and lifted only on remount, so a transient failure
left a control that does nothing. A click on that lock now re-reads. The
environment lock stays inert deliberately: the daemon would ignore the write,
and pretending otherwise is worse than doing nothing — and no packaged
install can reach it, since the packaged child env allowlist carries no
`OD_NEXT_*`.
After this, the only greyed state a packaged user can reach is the moment before
the first read returns.
Installations already holding a latch recover on their own: with nothing left to
read the row, their next run is admitted normally. No migration, and a downgrade
is safe — older code recreates the table, reads it empty, and treats the
installation as unlatched.
Coverage follows the same split. The config cases run against real files through
`readAppConfig` rather than against the policy reader in isolation, because the
hole was in the join: truncated JSON, a non-object body, an unrecognised mode, a
typo, a non-string — each reads `off`, while a missing file and an explicit
`null` still reach the default. One case asserts the whole chain, saving `off`
then truncating the file and checking the mode survives, with a fresh
installation as the negative control so the guard cannot swallow the rollout it
protects. The UI case fails the first status read, clicks, and asserts the row
becomes operable.
* refactor(daemon): drop the JSDoc left above the removed latch classifier
The block described `MACHINE_CONTRACT_BOUNDARY_CODES` and
`rolloutStopSignalForBlockedContinuation`, both deleted with the stop latch.
Left in place it reads as documentation for the export below it, implying this
service still classifies machine-contract failures when it no longer does.
* fix(daemon): tell operators why `od strategy rollout reset` is gone
The command shipped in 0.21.0 and 0.21.1. Removing it left the subcommand
falling through to the generic usage dump, so an operator who scripted it saw a
list it was simply missing from and had to guess whether the command was
renamed, moved, or broken.
It now names itself: the daemon-instance stop latch it cleared no longer exists,
so there is nothing to reset, and the message points at the two commands that
replaced its use — `od config set odNextStrategyMode off` to opt out, and
`od strategy rollout status` to see which authority decides the mode.
It still exits non-zero rather than succeeding as a deprecated no-op. The
command meant "recover this machine's rollout". Reporting success for a recovery
that neither happened nor can happen is the same lie the latch itself told —
a control that says it acted while nothing changed — and this release is
removing that lie rather than relocating it into the CLI.
* fix(daemon): stop a broken config file from opting an installation out
The previous commit fixed a real hole — a saved `off` could be silently revoked
— but fixed it too widely and in the wrong direction for this rollout. It
treated a config file that failed to parse at all as evidence of an opt-out,
which it is not.
A file that will not parse resets every preference to its default: agent,
telemetry, all of it. Singling this one key out to resolve against its default
was inconsistent with the rest of the file, and the population it hits is the
wrong one — everyone whose config breaks, not everyone who opted out. Since the
second group is a subset of the first, the guard opted more installations out of
the rollout than it protected, in a change whose entire purpose is opting them
in.
The guard now covers only what it can actually see: `odNextStrategyMode` present
with a value that is not one of the modes — a hand edit, a typo, a mode written
by some other version. Something was configured and cannot be read, so it reads
`off` rather than becoming "you chose OD Next". A fresh install, a missing file,
an unparseable file and a config without the key all reach the default, which is
`active`.
That keeps both invariants this step owes: the default is on for anyone who has
not said otherwise, and a user who did say otherwise keeps their answer. The
switch stays operable in every one of these states, so nothing here can leave a
user looking at a control that does nothing.
* style(daemon): drop the blank line left at EOF by the classifier removal
The line separated `resolveAutomaticContinuationEvidence` from the JSDoc and
classifier below it. Removing those left the separator as a trailing blank line,
which `git diff --check` reports. No logic change.
* docs(daemon): stop enumerating which releases shipped `rollout reset`
The comment named 0.21.0 and 0.21.1. 0.22.0 shipped today and carries the
command too, so the enumeration was already wrong the day it was written --
any list of shipped versions goes stale by construction. Say "every release
since 0.21.0" instead, which stays true without maintenance.
The point the sentence is making is unchanged, and slightly stronger: the
surface has been out for three releases, so an operator who scripted it gets
told what happened rather than shown a help list it is missing from.
---------
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
* fix(web): stop a compositor scroll clamp from silently killing auto-followThe chat log decides "did the user scroll away" from one criterion: the
position got smaller AND `scrollHeight` did not change. The file's own
comment justifies that with "content-driven displacement always comes with
a `scrollHeight` change" — true for content-driven displacement, false for
a compositor clamp.Chromium keeps its own copy of how far a scroller travels so a wheel does
not have to wait on the main thread. On the chat log that copy can go stale
while layout moves on. From a real user's diagnostic bundle (Electron 41 /
Chromium 146): scrollTop 245.5 layoutMax 718 scrollHeight 1307 unreachablePx 472.5Jump-to-latest writes `scrollTo({top: 1307})`, the position lands at 718.5
(the real layout bottom), and one wheel notch later the compositor throws it
back onto its own stale ceiling of 245.5. Content is static, so
`scrollHeight` never moves — both halves of the criterion hold, and every
clamp reads as one more user scroll-up. Auto-follow goes off without a word.The discriminator is wheel direction. A user scrolling up either carries an
upward wheel — which ChatPane's own `onWheel` already releases follow on,
ahead of the scroll event — or carries no wheel at all (scrollbar drag,
keyboard, touch). So "the only wheel in this window pointed down" is never
true of a user scroll-up; it says the user asked to move TOWARD the bottom.
A position that moves away from the bottom under that input is not their
hand. The same shape was measured independently for the freeze detector:
`scrollTop = 800`, one downward notch, thrown back to 91.The other candidates do not hold up. Landing on a known stale ceiling needs
the ceiling, which only the freeze detector's state machine can infer — and
`chat-scroll-takeover.ts` records that detector's false-positive rate as
unknown. The gap to `layoutMax` carries no information, since a user can
stop anywhere. Adjacency to a JS write is falsified by the bundle itself:
the clamp landed 3.8s after the write with zero JS write records in between,
and a window that wide swallows ordinary scrolls.This does not fix the stale ceiling — that is the takeover module's job and
it stays switched off pending a product call. It only stops the clamp from
also costing the user auto-follow.The criterion is deliberately biased to under-fire: a clamp with no wheel
witness is still treated as a user scroll-up. Welding follow on would be
worse than the bug.
* fix(web): give the wheel witness a lifetime
nettee's review on #7898 found the over-suppression this criterion was
supposed to avoid — a stale witness welding auto-follow ON, which is the
failure mode that is worse than the bug being fixed. The path is complete:
1. the user is already at the bottom and wheels down once — the position
does not move, so no `scroll` event fires and the witness is never
consumed;
2. switching conversations from the history control happens OUTSIDE the
log element, so no pointerdown/keydown reaches it, and the effect that
resets baseline and follow intent did not touch the witness;
3. a later NON-wheel move to earlier content (find-in-page, a focus-driven
scroll) is then compared against that stale downward witness, read as a
clamp, and follow never releases.
The witness now has three independent bounds, each covering a hole the other
two cannot:
· Consumed on use. Any scroll event spends it, including one that moves
nothing — one witness explains one displacement, never the next.
· Scoped to the scroll context. `WheelWitness.atScrollTop` records where
the scroller was; the displacement's origin must be exactly that, so a
baseline moved by anything else voids the note structurally rather than
on a timer. Conversation switch, log node swap and panel unmount clear it
alongside baseline and follow intent.
· Expires after a frame. The only bound that can kill a witness no scroll
event will ever consume. A frame is not a tuned constant: the clamp is
dispatched in the same rendering update as the notch that caused it, and
per the HTML update-the-rendering steps scroll events run BEFORE that
frame's animation callbacks. (Not to be confused with the 3.8s in the
diagnostic bundle — that is the gap between the JS write and the clamp,
not between the wheel and the clamp.)
Recording is skipped entirely when `requestAnimationFrame` is unavailable:
without the expiry bound a witness could outlive its displacement, and no
witness just means today's behavior.
Mutation-checked line by line. Three points are honest defence in depth and
say so in-place: the conversation-switch and unmount clears cannot be turned
red today (a conversation switch always schedules a frame, and `setTab` has
no call site), and the upward-wheel counter is pinned at the predicate level
only, because `onWheel` already releases follow on an upward notch.
* perf(od-next): streamline tool use and media input prompts
* fix(od-next): drop the runtime-state literal from the core tool rule
The v2.1.1 core prompt named `open-design-runtime-state` inside the
"not a tool name" bullet. In the two post-optimization eval batches the
model called that exact name as a tool in 5 of 29 deliveries (1 of 30
before the sentence existed): a negated mention raises the name's
salience right where the model is about to emit the runtime-state block.
Keep the rule, drop the literal: protocol blocks are structured text
output, not tools. Bump core asset to 2.1.2 and the bundled package to
2.0.2; orchestration is untouched and stays 2.0.1.
* fix(od-next): carry protocol output guidance into tool optimization
* fix(od-next): preserve run workspace scope in dynamic skill reads
---------
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>