* perf: check tunnel queue capacity before copying frame payloads
* fix(browser-tunnel): derive writer admission size from the frame encoder
Shares one encoded-length helper so the pre-encode capacity check cannot drift
from what encoding allocates, and covers the exact byte-cap boundary.
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* perf(terminal): release oversized backing strings behind pending controls
* test(terminal): record reproducible pending-storage gate evidence
* perf(terminal): own retained control fragments with a fast copy primitive
The pending-control ownership landed with a charCodeAt block copier (10 us
at 4 Ki, 170 us at 64 Ki), so it needed a "copy only when discarded output
dominates the tail" gate to stay affordable. That gate was the whole cost
problem: on adversarial streams it fires every chunk and pays the slow copy
(+45% on 16 Ki ANSI chunks, +81..111% on 194 Ki status chunks), and it also
skipped ownership on fragments too small to be sliced strings anyway.
ownRetainedString replaces it with a Buffer utf16le round trip (0.57 us at
4 Ki, 21.9 us at 64 Ki) and returns anything below V8's SlicedString
kMinLength unchanged. Buffer is absent in the renderer and on mobile, so the
copier is resolved once behind a lone-surrogate round-trip self-check and
falls back to the block copier. With a ~1 us copy the gate is unnecessary:
ownership is now unconditional at all three retention sites and the
adversarial cases land within noise of the un-owned parsers.
The three forced-GC threshold fixtures are replaced by one forced-GC test
for the primitive plus deterministic spy assertions that each site routes
its retained value through ownRetainedString. All fidelity and differential
coverage is kept.
* fix(terminal): escape the NUL in the round-trip probe
A raw NUL byte in the source made git treat the file as binary, so its
diffs and blame were unreadable. Escapes are equivalent at runtime.
* perf(terminal): skip plain text between partial escape sequences
* perf(terminal): take the ESC at hand before searching for one
The unconditional ground-state indexOf regressed dense back-to-back
SGR/CSI streams, where the code unit at the cursor is already the ESC and
the search pays call plus SIMD setup to find it in place. Check the
current unit first and fall back to the native search otherwise.
0.9 MiB dense SGR/CSI medians: 2.04 ms before this PR, 2.56 ms with the
unconditional search, 1.92 ms with the hybrid. Sparse colored logs and
plain text keep the full search win (0.36 / 0.016 ms vs 1.54 / 1.41 ms
baseline). Differential over the full VT alphabet with lone and split
surrogates matched 388,416 cases against both prior implementations with
zero mismatches; the ground-scan work budget now records 16 inspected
code units and 2 native searches.
* feat(native-chat): report Codex background tasks in the chat strip
The background-tasks strip works for Claude only; a structured Codex
session shows nothing in it. Feed it from the Codex app-server stream.
The strip stands for work that OUTLIVED a turn, which is what the
monitoring header, Claude's foreground suppression, and the conversation
command gate all already assume. Codex has no `is_backgrounded` flag, so
that fact is derived from the turn boundary: a `subAgentActivity` child or
a primary-thread `commandExecution` becomes visible once the turn it
belongs to completes and it is still unsettled.
`turn/completed` only reveals a task here, never settles one — measured on
`codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s
after its parent turn ended. Only a child's own activity kind settles it.
Codex exposes no honest stop: `turn/interrupt` on a child ends its turn
without emitting a terminal activity item and leaves its shell running. So
the state carries a new optional `supportsStopAll: false`, the strip hides
a control that could not act, and the blocked-command message asks the user
to wait rather than to press a button that does not exist.
* refactor(codex): move session teardown out of the structured adapter
Merging main crossed the 300-line cap on
`codex-structured-session-adapter.ts`: the rewind backend (#19235) and this
branch's close-time strip clear both landed in it. The four close paths move
verbatim into `codex-structured-session-teardown.ts`, where they funnel
through one `settled` helper instead of repeating the notification-retry and
background-task cleanup at each call site. No ratchet bump.
Also normalize a background task's description once at receipt rather than on
every projection; the roster is re-projected on each observed frame.
* fix(codex): drop the shell row the journal already settles
A `commandExecution` still `inProgress` when its turn ends was reported as a
`command` task. But `settleCodexJournalTurn` writes exactly those items to the
journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip
row would have claimed a shell was still running at the same instant Orca
recorded that it was not — two surfaces contradicting each other about the same
process.
A subagent is the opposite case and stays: the roster pointedly does not sweep
at a turn boundary, because children measurably outlive it. That leaves the
producer making exactly one claim — these spawn_agent children are still live
after their turn — which the durable roster row corroborates.
* fix(native-chat): track Codex background execution lifetimes
* fix(native-chat): keep running tool groups from claiming completion
* Fix runtime catalog and capability expectation
* fix(codex): keep a child's name on the command row that outlives it
A child agent's commands stay hidden behind its agent row while the child
works. Once the child's turn settles with a command still running, that
command surfaces as its own row labelled from the raw command string, so
'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'"
at the moment that row was the only remaining signal for the work.
Qualify a child's command row with the child's label. Resolved on read,
so a label registered after the command still lands, and bounded by the
existing description cap so admission accounting stays valid. Primary-
thread commands are left unqualified: they have no child to name.
---------
Co-authored-by: Merge Sim <sim@local>
* fix(orchestration): file mail from terminals in no Run under an unbound Run
#19542 deleted the fallback that filed such mail under the legacy Run, because a
live row there makes the schema-skew probe read the database as pre-Runs and
replay adoption on the next open. That refusal also broke the first command in
the guide: `orca orchestration send --to <handle>` between two plain terminals,
which worked in v1.4.198.
Restore delivery by filing under `run_unbound`, a Run the probe never matches,
created on first use so `run list` shows it only to a user who has such mail.
Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9
* fix(orchestration): create the unbound Run only for a null Run id
Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9
* fix(orchestration): stop worktree ps reporting a busy structured session as idle
A worktree running a structured Claude or Codex chat read as idle to `orca
worktree ps`, while the desktop sidebar showed the same session working. The
sidebar was right: the host already projects a status summary for every
structured session and publishes it, and the renderer maps it into an agent
row. `worktree ps` simply never consumed it, so the agent-facing surface was
the blind one.
Structured sessions have no PTY, so they reach neither the hook snapshots nor
the retained ones that every other row is built from. This reads the summaries
the host has already published and applies the same projection the sidebar
does — working, attention as blocked, otherwise done — so the CLI and the GUI
cannot disagree about one session.
Two things worth knowing:
The connected-PTY evidence gate had to be skipped for these rows. It exists to
drop a row whose PTY is gone, which is the wrong question for a session that
never had one; a structured row's liveness evidence is the status feed that
produced it. The exemption is keyed on the row being structured, so every
PTY-backed row keeps today's behaviour.
`RuntimeWorktreeAgentRow` needed no change. It was already a non-PTY shape —
paneKey, state, agentType, and no ptyId, connected or writable — so a
structured row fits without inventing a fake terminal coordinate.
The pane key is the DERIVED one the renderer already publishes, never the
orchestration bearer handle or the minted worker pane key: both are
credentials, since `orchestration check` is identity-gated and accepts a
caller-supplied pane key.
`orca terminal list` is deliberately untouched, and a test pins that. Adding
rows there breaks real consumers: mobile mounts a terminal WebView per row that
can never receive a frame, a `connected`-keyed refresh check goes permanently
true and pins shipped clients to a fast cadence with no exit, and the plugin
projection has no field that can carry `writable: false`. Every safe consumer
of a terminal summary checks `ptyId`; the breaking ones key off `connected` or
mere row presence, which no added field can qualify. An honest partial-listing
count there is a separate change.
* fix(runtime): report only live structured sessions in worktree ps
The status feed's `published` map is a broadcast cache, not a roster. It
deliberately never retracts — an evicted idle session is still idle, and a
reloading renderer must not lose every settled row — so enumerating it lists
every session the host has ever opened, and eviction's `forget-session` step
deletes the session from the live map while touching nothing else.
Reading it as a roster made `worktree ps` report a closed chat forever. The
sharp edge is a chat closed while an approval was pending: a deliberate close
does not settle a pending prompt, so the retained summary stays `attention`,
maps to a `blocked` row, and merges the worktree to `permission` for the whole
30-minute freshness window — on the CLI and on the mobile sidebar it backs.
The poller now answers from the sessions the host still holds, intersecting the
live map with the retained projections. `subscribe()` and its snapshot are
untouched: retention there is the point. Gating on the live session set rather
than the visible tabs keeps a headless orchestration worker listed, which is
what the agent-facing surface is for.
Also folds out two things the enumerator left behind: the working/attention/idle
to working/blocked/done mapping now lives once in the shared projection module
instead of once per process, which is what actually enforces "the CLI and the
GUI cannot disagree"; and the structured row source no longer builds a
write-only `payload` behind an `as` cast that compensated for nothing. The
structured source construction moves to its own module to keep
runtime-worktree-agent-rows.ts clear of the 300-line cap.
* test(runtime): execute the structured-host call site in worktree ps
Both structured-row suites called attachRuntimeWorktreeAgentRows directly
with summaries they built themselves, so nothing ever ran getWorktreePs's
own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`. That
file carries `@ts-nocheck`, so renaming the accessor was green in typecheck
and in the suite, while `orca worktree ps` and mobile's 3s poll would throw
a TypeError for every user — the `?.` optional-chains the host, not the
method. Swapping the call back to a whole-cache read was equally invisible:
the liveness suite injects feed.liveSessionSummaries() itself, and the
string-match guard only needs the identifier to appear somewhere in the file.
Drives the real runtime with a stub host over a real status feed that has
published two sessions and forgotten one, asserting the live session's row
reaches ps output, that the live accessor is the one called, and that the
`?? []` fallback still returns a page with no host installed. The stub is
typed against the real host, so a class-side rename reddens tc here.
* refactor(runtime): admit agent sources before worktree row projection
* fix(runtime): preserve host-authoritative structured status
* Fix structured host session activity lifecycle
---------
Co-authored-by: Merge Sim <sim@local>
* feat(native-chat): show Claude subagent activity on the shared carrier
Claude's `message:system:task_*` frames are classified `status-chrome` and
reach the transcript as nothing at all, so a turn that spawns subagents
renders as an idle turn. The journal translator now reads them into the
shared subagent-group carrier — no new UI, and the frames stay
`status-chrome` so nothing prints a raw opcode row.
`local_agent`, `local_workflow` and `local_bash` tasks share that channel
and all carry a `tool_use_id`, so `task_type` is the discriminator and a
backgrounded `sleep 20` stays out of the roster; `subagent_type` covers
releases that predate `task_type`. `skip_transcript` tasks never render,
`is_backgrounded` children survive the turn-end sweep, and a resumed task
re-announced under a fresh tool id is aliased onto its `task_id` rather
than duplicated.
A child still reported as working when the turn — or the session — ends
becomes `unverifiable`: contact was lost, which is not evidence it exited.
* fix(native-chat): stop the Claude subagent roster dropping its own rows
The roster published under the same coalescing key it appends the row
with, and the sink queue replaces any queued operation sharing a key
regardless of kind: once a write was in flight, each new append evicted
the pending publish and the next publish evicted that append, so the
body never reached the journal and `lastSerialized` had already moved
past it. Publish now takes the sink's own slot, as the Codex streams do.
A tombstoned row could never come back: the non-batch item-row builder
derived its revision from `items` alone, so a re-add was built at
revision 1 against a tombstone at 2 and the reducer discarded it
forever. It now takes the same `max(items, tombstones)` the batch
builder already used — reachable here because an announcement that
reveals a `local_bash` task empties and tombstones the group row that
a genuine subagent later in the turn reuses.
`settleTurn` swept whatever group the key named at the time it ran, so
children rostered before any turn key existed were never swept, and a
turn whose result never arrives was left working forever. The ending
turn's key is now an argument, a superseding turn start settles the
turn it replaces, and every turn end also sweeps the outside-turn
group. Teardown without an `ended` event, and eviction past the group
bound, both lose contact instead of stranding a row at `working`.
Label ordinals are a high-water mark now: releasing one on a re-label
handed the next child an ordinal that was already on screen.
* fix(native-chat): bound subagent-group blocks on every wire that carries one
Adding a fifth arm to `NativeChatBlock` made every consumer that assumed
four wrong. Two of them ended in `return block`, so they compiled while
handing a roster straight through: the mobile RPC sanitizer shipped it
unclipped past both mobile char caps, and the legacy transcript import
stored an untrusted roster unbounded. Both now clip each label and cap
the entry count the way they bound their other blocks.
The remaining three sites did not compile at all. The worker transcript
payload and the live-session benchmark get real arms rather than casts —
a cast would have turned the transcript one into a third silent
passthrough inside the wire byte budget — and the CLI worker output
renders a roster with its shared summary instead of `[image omitted]`.
The mobile sanitizer moves to a sibling module beside the image-block
one: the file sat exactly on the max-lines bound, and the block bounds
are a self-contained concern with their own caps.
Also caps the roster's `subagent_type` label fallback, which reached the
journal uncapped, and covers the new block type in the schema audit.
* test(native-chat): cover the capped subagent_type label
The roster stores the frame's label verbatim, so the cap on the
`subagent_type` fallback is the only thing bounding it.
* fix(native-chat): type the roster fixture so the suite typechecks
The mobile-cap test built its entries with an inferred `state: string`, which
is not a `NativeChatSubagentState` — the only typecheck failure on the branch.
* fix(native-chat): stop child traffic rostering an id Claude never announced
`observeChildActivity` minted a provisional row for any `parent_tool_use_id`
outside the excluded set. An id that was never announced is never excluded, so
a nested Task, a workflow child, or a grandchild parented to a tool id inside
the sidechain each produced a permanently unlabelled `subagent` row that could
only ever end `unverifiable`. The bounded exclusion set cannot cover an id no
frame ever declared, and in a long session it can forget a genuine exclusion.
Track instead whether this CLI announces tasks at all — set by ANY
`task_started`, including one the subagent filter rejects. Once it has, an
undeclared child is provably not a new subagent, so no row is created. The
provisional path now serves only releases that announce no task frames, which
is what its comment already said it was for.
The label-ordinal test moves to an announcement-driven removal, the scenario
that path now actually reaches; it still fails if `remove` releases the ordinal.
* fix(native-chat): outrank the tombstone when building one too
`buildJournalTombstoneRow` still built its revision from `items` alone, leaving
it asymmetric with the item builder. It is correct today only because
`upsertItem` clears the tombstone whenever a re-add wins — an invariant that
lives in the reducer and was not pinned. Apply the same `Math.max`, and pin the
invariant so the reducer cannot drop it silently.
* fix(native-chat): stop an unrelated turn end settling an outside-turn child
`settleTurn` swept the `outside-turn` group on every turn end, so a child
Claude announced while no turn was live — a frame trailing the previous
turn's result, or one that arrives before the first turn starts — was
marked `unverifiable` by the next, unrelated turn ending. That state is
terminal and latches, so the `task_updated: completed` that followed was
discarded: loss of contact was recorded as the child's outcome on
evidence that was never about that child.
A turn end now sweeps exactly the group its key names. `outside-turn`
belongs to no turn, so only an end with no key of its own reaches it, and
what no turn end reaches `settleSession` does — reliably, since teardown
without an `ended` event also routes through it. The cost is a child
outside every turn showing `working` a little longer; the alternative
prints a wrong outcome that nothing can revise.
Also pins that a subagent announced after a task the filter rejected
still rosters: the announcement path was never what the child-traffic
gate closes.
* fix(native-chat): bound a subagent entry's id, not just its label
Every site that bounds a `subagent-group` block clipped the label and
handed the id through whole. From the Claude producer the id is bounded
upstream, but the legacy transcript import reads an untrusted file, so an
oversized id survived into the journal and then out to every wire that
replays it — 64 entries of it, since only the entry count was capped.
Each site now clips the id with the helper it already uses for its other
bounded fields: the journal's inline-text bound on import, the
transcript payload's metadata clip, and the mobile char cap (renamed,
since it is no longer a label-only cap).
* fix(native-chat): surface an adverse subagent outcome in the fallback sentence
The roster row's plain-text stand-in counted only `working`, so a fan-out whose
children all latched `unverifiable` (or `failed`, or `stopped`) rendered as
"Ran 3 subagents" — a completion claim. Mobile and paired web have no roster
renderer, so that write-time-frozen sentence is the entire row there, and
collapsing `unverifiable` into something that reads like success is exactly what
the SSH execution boundary forbids.
It now appends the worst adverse count, worst-first across failed/stopped/
unverifiable, and shows it even while siblings still work — matching the Codex
lane's shared `subagentGroupFallbackText` verbatim so collapsing the two copies
later is a deletion, not a behaviour change.
Also bounds the provisional entry id. `observeChildActivity` wrote the
`parent_tool_use_id` straight into the entry's durable id with no length cap,
while the announced path already rejects an over-long id via `claudeTaskId`.
Both now share `isBoundedClaudeTaskId`, and the provisional path rejects rather
than truncates, as the announced one does.
* fix(native-chat): stop a subagent label ordinal and a clipped roster key colliding
- claimLabel probes the labels the group actually rendered instead of a
per-base counter, so a generated `Audit 2` cannot duplicate a provider's
own `Audit 2`.
- Bound `NativeChatSubagentEntry.id` with a head plus a digest of the whole
id at every site that bounds it. The id is the roster key: a prefix clip
merged two distinct children onto one entry.
- Correct a stale journal-reducer test comment: tombstone cleanup is a
map-state invariant, no longer load-bearing for revision ordering.
* fix(claude): merge duplicate unhandled-provider-frame imports
* fix(claude): preserve subagent lifecycle and bounded invocation identity
---------
Co-authored-by: Merge Sim <sim@local>
* fix(orchestration): let worker-start actually produce a structured chat
`orchestration.workerStart` reads the user's "open agent tabs in chat"
default, but two placement checks downgraded a structured-preferring
worker to a PTY terminal agent for the two flags a routine dispatch
always passes:
--worktree new-child / new-top-level -> worktree_creation
--model / --effort -> launch_preferences
so in practice a structured worker never happened.
launch_preferences was stale. PR #19040 gave AgentSessionAttachParams
`options` and added resolveStructuredLaunchSeedOptions, which narrows a
saved selection to exactly `model` and `effort` — the two ids both
structured providers accept as strings. --model/--effort now go through
that same narrowing (extracted as narrowStructuredLaunchSeedOptions) and
seed the worker's session instead of forcing a terminal. An option set
that narrows to nothing resolves to undefined, never `{}`, which would
fail the record's bounded-string guard under a code that is not a wire
refusal and strand the launch with no fallback.
worktree_creation was a consequence of createWorkerWorktree creating
agent-first: its startup terminal WAS the worker, so the structured
branch below it was unreachable for any new worktree. A structured
worker now creates the worktree with no startup agent and creates its
session for the worktree afterwards — the order the renderer's own
structured worktree create already uses. Because the executing host can
only answer agentSession.createSupport for a workspace that exists, that
verdict moved after creation: a refusal (WSL, and the rest) becomes a
terminal agent in the worktree just created, never a failed start.
--on and --terminal still downgrade, with their reasons intact, and
every remaining downgrade still states itself in the mode receipt.
The wait-for-setup gate is preserved explicitly. A PTY worker got it for
free — agent-first creation sequences the agent's startup command behind
the setup runner, so tui-idle could not arrive until setup exited. A
structured session has no startup command to sequence, so the gate is
now awaited directly, bounded by the start's own timeout.
Split out worker-worktree-creation.ts and worker-start-agent-placement.ts
rather than growing two files that were both pinned at the max-lines cap.
* refactor(native-chat): make shared feasibility authoritative for launch routing
* Type the structured setup gate's absent blocked reason so the wait union stays property-typed
The type-aware audit rejected the blocked-reason template literal: narrowing the
wait union with an 'in' check left the field typed unknown. Declaring that a
structured setup gate never carries a blocked reason restores the direct read.
---------
Co-authored-by: Merge Sim <sim@local>
* perf: count command-line escapes without regex match arrays
* test(windows): pin command-line budget counting against the regex oracle
Covers every BMP code unit, astral and lone-surrogate adjacency, trailing
backslashes, %VAR% and carets, plus randomized quote-heavy command lines.
* perf(windows): count command-line escapes by seeking, not scanning
Counting every character regressed the shape this estimator actually guards: a multi-KB WSL script with almost no escapes went 25-38x slower on Windows. Seek escapes with indexOf so the cost tracks their count, and hand the rest to a plain scan once they are dense enough to pay for it.
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* perf: cache update timestamps for Linear and Jira result sorting
* perf(issues): build updatedAt key map without an intermediate tuple array
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
* perf: scope activation inventory to the owning host and workspace
* fix(activation): keep an unscoped census fallback when the owning host is unnameable
Scoping the activation inventory made resolveActivationPtyListScope throw for
paired-runtime workspaces and made a detached relay reject the scoped list, and
both collapse to a 'blocked' gate. 'blocked' skips the sleeping-agent resume and
the caller's reseed, so an SSH target on the bounded offline floor lost its
initial pane and peer workspaces stopped resuming.
Fall back to the unscoped inventory that shipped in exactly those two cases; the
scoped fast path still covers local, folder and attached-SSH workspaces. Also OR
the host-reported worktreeId with the id-prefix match instead of preferring it,
because a relay seeds worktreeId from the host's own ORCA_WORKTREE_ID and a
session dropped from the census is one the gate forks a second writer onto.
* test(activation): update forkbomb fakes to the scoped session.tabs.list shape
The gate now asks the host for one workspace's snapshot instead of the whole session.tabs.listAll inventory and refuses an answer that does not name its scope, so the old snapshots-array fakes made it block instead of resume.
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* perf(terminal): reuse forward OSC status terminator searches
* test(terminal): pin cached OSC terminator reuse across BEL frames
Document that forward match reuse requires a monotonic search offset and cover a distant ST held across many intervening BEL frames.
* perf: preserve store state on unchanged document titles
* fix(browser): compare every doc-history field before skipping a title refresh
A hand-listed title check would silently swallow any field added to
WorkspaceDocHistoryEntry later. Cover the over-cap trim path too.
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* fix(pi): carry modal waits to mobile and stop losing the dialog close
Follow-ups to #18836, from its readiness review.
- Paint pi's `!` needs-input state marker while a dialog is open, so the
80ms spinner frame stops repainting a working title over a mid-turn
wait. Mobile and the CLI read the title, so they saw `working` where
the desktop already showed `waiting`.
- Keep the assistant reply that lands while a dialog is open. The modal
guard cleared tool fields and the `message_end` capture with them, so
a turn ending under a dialog left the preview on the previous message.
- Report `ui_prompt_end` even when `ctx.isIdle()` throws on a runner the
modal itself invalidated; the lost post stranded the pane on `waiting`.
- Declare the `esbuild` the runtime smoke tool imports.
* fix(pi): hold the needs-input marker until the dialog actually closes
From review of the previous commit.
- Settling under an open dialog no longer retires the marker. stopAnimation
painted the plain title unconditionally, so agent_settled, a resolved
agent_end, or an idle auto_compaction_end erased it mid-dialog — and
because that also cleared the timer, the close then painted the plain
title again and the wait was lost for good.
- Track the dialog as a boolean, not a depth counter. Pi does its own
nesting accounting and emits one pair per stack, which is what the status
extension already assumes; two files disagreeing on that would have let an
inner close release the outer wait.
- Reset the flag on agent_start in both extensions. A turn cannot begin under
a dialog holding input focus, so it is the one boundary that can recover a
close that never arrived instead of pinning the pane forever.
- Leave OMP to its approval events: it reports waits through those already,
and painting the marker there too would put title and hook in disagreement.
* fix(pi): do not ring the completion bell for a dialog that lost its close
From review of the previous commit.
- Report working, not done, when ui_prompt_end's isIdle() throws. done is
not cosmetic: it reaches dispatchCompletion and fires the pane's finished
notification, so a turn that is still running would announce itself. The
real done still arrives from agent_end/agent_settled.
- Keep the idle-maintenance frame cap accruing while a dialog holds the
title, so a dialog left open cannot suspend the guard that stops a
compaction spinner whose end event never came.
- Guard the dialog handlers against a ctx without ui. The source is
generated and untypechecked, and pi does not document the ctx it passes
these two events; a TypeError there would surface on every dialog.
* fix(pi): let a turn still complete after a dialog loses its runner
From review of the previous commit.
- Re-arm the completion report when ui_prompt_end's isIdle() throws. The
fallback posts working, but the finished turn had already reported its
end, so nothing further would ever fire and an idle pane sat spinning.
- Count dialog depth in both extensions instead of trusting pi to emit one
pair per stack. The guarantee is undocumented, and if it ever does emit a
pair per dialog, an inner close would release the wait the outer dialog
still holds. A counter costs nothing and drops the dependency.
* fix(pi): decide a dialog close from turn state, not from a guess
From review of the previous commit.
- Fall back to agentEndReported when ctx.isIdle is unavailable or throws.
The previous guess of working stranded the common case — a dialog opened
at idle — because no later event was coming to correct it, and the
agentEndReported re-arm it relied on could not fire either. A turn that
already reported its end is not still running, and that is knowledge this
process holds without needing ctx at all.
- Only suppress spinner frames once the marker is actually painted. Pi may
pass a ctx with no ui, and freezing the title on its last working frame
is the opposite of what the marker is for.
- Gate the titlebar dialog handlers on the OMP runtime too, not just the
installed kind: a bare-shell OMP launch runs inside a pi-kind pane, and
the status extension already defers there. Extracted that check so both
extensions share it rather than carrying two copies.
* fix(pi): treat a pane that never ran a turn as idle, not busy
From review of the previous commit.
- Track turn-in-flight separately from agentEndReported. That flag also
dedupes the completion post, so it starts false on a pane that has not
run a turn — which read as still-running and left a dialog opened before
the first prompt spinning forever.
- Retry the marker paint on each dialog open instead of only the outermost,
so an outer ctx without ui cannot decide the whole nested stack goes
unmarked.
- Fall back to the opening ctx when the close carries no ui. Nothing else
clears the needs-input marker, so the pane would have kept asking for
attention until the next turn.
* fix(pi): keep a dying dialog ctx from stranding the needs-input marker
The close path paints through the ctx captured at open time, which is the
one a session-switching modal is most likely to have invalidated. Guard
both paint sites so a throw cannot reject the handler and leave the title
on the needs-input marker, and make local turn state the floor for the
status extension's idleness verdict instead of a fallback.
* fix(pi): hold the dialog wait against pi's own title writes and lost closes
Reviewed against real Pi 0.85.1 source rather than inference:
- ctx.ui is a getter that calls assertActive() and throws once a session-
replacing dialog invalidates the runner, so optional chaining never
screened it out and the probe sat outside the try. A throw landed after
the depth decrement but before markerPainted cleared, stranding the
needs-input marker until the next turn.
- Pi writes the same terminal title from its own writers with no event we
observe, so the marker is now re-asserted rather than merely not
overwritten, on a slow timer that outlives the spinner and its cap.
- resetExtensionUI drops an open dialog without resolving its promise, so
a replaced or reloaded session never emits the matching ui_prompt_end.
Both extensions now release the wait on session_start and shutdown.
* fix(pi): build the title inside the guard, not as an argument to it
paintTitle caught the setTitle throw but not the two calls one argument to
its left: pi.getSessionName() asserts runner liveness the same way ctx.ui
does, and process.cwd() throws ENOENT once the worktree is unlinked under a
live pane. Four of the six call sites are timer callbacks, where an escape
is an uncaught exception and pi exits(1) through its own handler — so the
cwd route was reachable today. paintTitle now takes a builder and runs it
inside the existing try.
* fix(pi): let only the pane-owning process assert the needs-input marker
The spinner is harmlessly per-process, but the marker is status the pane
reports, and child agents inherit ORCA_PANE_KEY. Gate the two dialog
handlers on a PID claim, mirroring ORCA_PI_STATUS_OWNED in the status hook.
* Remove settled-worker automatic resume and hibernation fences
* test: retirement rollback case follows the no-fence policy
Case 4 seeded and asserted automaticResumeBlockedBy, which this branch
deletes. A rolled-back settled worker is now an ordinary done record that
wake clears as passive evidence, same as any finished agent pane.
* chore(i18n): regenerate the runtime-required catalog for the contrast floor strings
* test(orchestration): give the stopping-worker guard fixtures a Run
* feat(terminal): make the contrast floor user-configurable (#10754)
The xterm minimumContrastRatio floor was hardcoded (3 on dark backgrounds,
4.5 on light) and applied to every pane with no way out, so TUIs that use
deliberately low contrast were rewritten: Powerline separators drawn in the
neighbouring segment's background became visible seams, and dimmed secondary
text lost its hierarchy.
Adds an optional `terminalMinimumContrastRatio` setting under Settings ->
Terminal -> Rendering. Blank keeps today's automatic, background-luminance
gated floor; 1 disables correction entirely (matching VS Code's documented
`terminal.integrated.minimumContrastRatio` and iTerm2's off-by-default
Minimum Contrast); values are clamped to xterm's 1-21 range.
The floor is resolved in one place, so live panes, the Appearance preview
and the dashboard terminal preview all follow it, and the existing
value-gated write still avoids clearing xterm's contrast cache on no-op
re-applies. The clamp also lives at the persistence boundary that every
writer crosses, so a hand-edited profile or CLI write can never hand xterm
a non-finite option. Mobile mirrors the desktop gate, so the resolved floor
travels with the terminal theme payload as a new optional field; hosts that
omit it leave older and newer clients on the luminance gate.
Fixes#10754.
Co-authored-by: Nyanako <44753291+Nanako0129@users.noreply.github.com>
* fix(terminal): refresh mobile payload fixture and clarify contrast target
* feat(terminal): make contrast controls intent-based with custom tuning
---------
Co-authored-by: Nyanako <44753291+Nanako0129@users.noreply.github.com>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
* perf: lazily index case-insensitive Windows environment keys
* test(windows): pin env expansion fallback against the per-miss lookup oracle
Adds zero-enumeration, first-case-variant-wins, prototype-chain and 4,000-case
randomized differential coverage, and groups the new cases under their describe.
---------
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
`extractPartialEscapeTail` broke its own fold invariant
(extract(a + b) === extract(extract(a) + b)) in the oscEsc/stringEsc states,
so a PTY read that split there produced a different pending tail than the
same bytes delivered whole — the tail snapshots append after a restore.
Two causes, both in the "ESC did not terminate the string" branch:
- CAN/SUB were routed through `stateAfterEscByte`, which maps them back to
`esc` instead of aborting to ground. `extractPartialEscapeTail('\x1bPx\x1b\x18X0abc')`
returned '\x1b\x18X0abc'; the chunk-split fold returned ''.
- A second ESC opened its new sequence at `i - 1` rather than at itself.
`extractPartialEscapeTail('\x1b] \x1b\x1b^')` returned '\x1b\x1b^' whole but
'\x1b^' folded. The fold was right — xterm starts the sequence at the second ESC.
The existing fuzz only asserted the fold as `advance(extract(pending), chunk)`,
which is a tautology because every PENDINGS entry is already a tail. Replaced
with a sweep that re-splits the combined stream at every code-unit boundary,
and extended the alphabet (NUL, 0x20 intermediate, CJK) and SEQUENCES with
CAN/SUB and doubled-ESC-inside-string cases. A 1.25M-split fold fuzz over a
VT alphabet goes from 421 failures to 0.
* perf(native-chat): preserve historical tool rows while streaming
* perf(native-chat): short-circuit identical rows and lock producer immutability
Most folded rows come back as the input object, so compare identity before
scanning fields and blocks. Add a regression test for the invariant the reuse
cache depends on: ordering and folding never rewrite producer-owned messages
or blocks, which reused rows alias.
* Update PR checks fix prompt to verify failure causality before fixing
Revise the prompt to classify failures as caused by this branch, not caused,
or uncertain before making changes. Only proceed autonomously for confirmed
failures; ask the user for guidance on uncertain or unrelated issues to avoid
fixing failures that weren't caused by the branch.
* Update PR checks fix prompt to verify failure causality before fixing
- Emphasize investigation phase by reframing prompt: "Investigate" rather than "Fix"
- Extend untrusted-data warning to all investigation sources (repository files, commit messages, diffs, CI output)
- Add test verifying injection safety: malicious input confined to JSON payloads, never as prompt instructions
* Refactor buildFixChecksPrompt test to focus on field mapping
The wrapper's only responsibility is renaming mobile PR fields onto the
shared prompt builder. Remove assertions about prompt wording, which are
already covered by the builder's own test suite. Simplify the test to
verify the field mapping contract and nothing else.
* fix(pi): show input modals as waiting instead of working
* test(pi): verify real input dialogs through Electron CDP
---------
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* fix: preserve terminal retirement proof across renderer publications
* refactor: share the live-surface filter between retirement proof preservation and projection
The publication projection already dropped proofs whose surface is live;
reuse that as one helper instead of a second inline scan.
* fix: emit stored retirement proofs from host-authored snapshot writes
Three callers built a snapshot, stored it, then emitted the pre-store object. Storing grafts on the preserved proofs, so those frames carried the stored snapshotVersion without the proofs; subscribers dedupe on version and never saw them.
* fix: send terminal retirement proofs once per stream and fence them by occupant
Proofs are pinned per worktree for the host's lifetime, so every snapshot publication — including a 50ms title tick — re-shipped up to 64 proofs (~17 KB on realistic ids) to every paired client.
Negotiate session-tabs.retirement-proof-delta.v1: the host projects each session-tabs stream to send a proof only the first time that stream carries it, and a capable renderer keeps the union in a ledger keyed by (environment, worktree) with the same 64-entry bound and the same live-surface drop rule as the host, reset on removed frames and on a new connection generation. Legacy clients keep receiving the full list; CLI and mobile do not advertise the capability.
Also inherit worktreeInstanceId onto identity-less host writes so a host write between two renderer occupants can no longer launder one occupant's proofs into the next.
* fix: keep an empty proof delta distinguishable from a proof-less host
A negotiated stream now sends retiredTerminalSurfaces: [] when nothing is new instead of omitting the field. Absence is the host's "I hold no proofs" signal — which is also what a recreated worktree's fresh host entry publishes — so the client ledger forgets on absence and a successor occupant never inherits its predecessor's proofs, even when the removed frame was missed.
* test: pin ledger visibility against a legacy full-list host
An old host sends the full proof list whenever it holds any and omits the field when it holds none. Prove the new client ledger shows exactly what a legacy client would see across that sequence, so forgetting on absence is verified not to regress the mixed-version case.
* feat(native-chat): read a tool batch as a group
A run of several tool calls collapsed to one joined string: names and
arguments run together, separated by a middle dot that also occurs inside
`browser.open` and `tools/read`, with the overflow cut mid-token. Opened,
the member rows sat flush with the header and with the message content
around them, so the batch had no visible end.
Two presentation changes, no new derivation:
- Each member gets its own bounded pill in the collapsed header, carrying
its own category glyph, so the boundary between calls is a shape rather
than a character. Pills wrap instead of truncating, and members past the
summary cap are counted in `+N more` rather than dropped silently.
- Opened members are indented under the header, which is what marks where
the run ends.
`toolRunSummaryMembers` keeps the run's leading calls apart instead of
pre-joining them; `summarizeToolRun` now derives its string from it, so
mobile's header is byte-identical and the two cannot disagree about which
calls speak for a run.
Two existing behaviours are pinned by test rather than changed, both being
naming decisions rather than layout ones: the header still prints the raw
`mcp__linear__list_issues` while the row beneath prints the split name, and
a call carrying only a `url` still falls through to a JSON preview clipped
at 28 characters.
* fix(native-chat): bundle hidden tool count copy
* fix(native-chat): drop the filled pill for a glyph-led member list
Rendered in the app, the filled chips were wrong twice over. `bg-accent` is
reserved for hover/active row backgrounds, and the only full-strength use of
it in native chat is on payload and diff surfaces — so each member read as a
shrunken content block, and a run became the loudest thing in the transcript.
Worse, `flex-wrap` degenerated: at a 297px pane each member is 274-288px, so
every one took its own line, the header grew 24px to 72px, and the `5x` count
centred against the block landed beside the second member as though it counted
that call alone.
The glyph already marks where a member starts, so the fill was carrying no
information the icon wasn't. Members are now inline, glyph-led, and separated
by spacing; the list stays one line and truncates as a whole, as it did before
this branch. `+N more` moves outside the truncating span so the count of what
is not shown survives a pane too narrow to print the list.
Members carry `data-tool-run-member` rather than being found by their fill.
* fix(native-chat): let the run summary size to its content
`flex-1` on the truncating member list made it claim the header's slack, so
`+N more` was pushed to the far right edge with a gap between it and the last
member it counts. Without it the span still shrinks and truncates — `min-w-0`
plus the default shrink is what drives the ellipsis, which is how the header
worked before this branch — and the count now sits directly after the list at
every width.
* fix(native-chat): separate run-header members with real whitespace
An `ml-3` margin marks the boundary on screen but is invisible to a copied
selection and to the button's accessible name, so the header read
`ls -latools/read`. Adds a space text node between members and trims the
margin to pay for its width. `+N more` also picks up the hover transition
every other header segment already had.
---------
Co-authored-by: Merge Sim <sim@local>
* fix(native-chat): render compaction notices, plan documents, and images
* fix(native-chat): avoid repeating notice text in details
* fix(native-chat): journal canonical and legacy compaction events
* test: add digest to native chat notice payload fixture
* chore(native-chat): drop the planning doc from the PR
---------
Co-authored-by: Merge Sim <sim@local>
* fix(native-chat): scope composer file drops to the pane that received them
A native OS file drop resolving to `target: 'composer'` carried no pane
identity, so the window-wide payload was attached by every mounted composer.
Because inactive chat tabs stay mounted (hidden), one drop populated every
chat pane's attachment cache, and those chips replayed whenever the user
returned to a tab they never dropped into. The workspace-creation composer
and chat composers also leaked into each other, since neither could tell
which surface actually received the drop.
Composer drops now carry a `scopeKey` the way a terminal drop carries its
tab and pane leaf id: the composer publishes its pane key as
`data-composer-scope-key`, the preload harvests it during the composedPath
walk, and each composer attaches only its own. The workspace composer's
last-wins ownership stack now claims unscoped payloads only.
* test(native-chat): supersede the bug-asserting drop repro with the scoping test
The repro that landed on main asserts the pre-fix behavior (a drop reaching
every mounted composer), so it fails once drops are scoped to the pane that
received them. Its scoping cases now live in
native-chat-composer-drop-scope.test.tsx, which keeps its editor-target
control case verbatim and adds coverage for unscoped composers and a scope
key published inside the drop-target marker.
* test(native-chat): cover workspace composer drop isolation
* fix(native-chat): authorize external attachment paths before preview
---------
Co-authored-by: Merge Sim <sim@local>
* fix(native-chat): stop an unanswered host from reading as one that refuses structured chat
`readLocalRuntimeCapabilities()` returned `[]` both before the first status probe
landed and after one failed, so "not asked yet" and "host says no" were the same
value. Every structured-chat launch route consumed it, and an unprobed host was
routed to legacy chat exactly as a refusing one is.
Keep the two apart: the cache holds `null` until a probe succeeds, a failed probe
leaves it `null` rather than emptying it, and the launch route names the case with
its own blocker instead of borrowing `runtime-capability`.
No routing outcome changes — both cases still decline structured chat. The point is
that the reason is now truthful, which is what the routing work needs to build on:
once a launch can target a runtime peer, capabilities come from that host, and an
unanswered remote must not be indistinguishable from one that refuses.
`hostCapabilities` on the launch route stays local-only at every call site; a
per-target resolver replaces it when the route learns to reach a peer.
* test: cover unknown runtime capability lifecycle and launch fallback
---------
Co-authored-by: Merge Sim <sim@local>
* Persist agents sidebar search field visibility as pairing-local preferen
- Add `agentsShowSearch` to workspace UI state with default on
- Include in pairing-local fields so preference syncs across clients
- Convert search from menu action to checkbox menu item for explicit toggle
- Update activity thread options menu to reflect checkbox state
- Add localization strings across all supported languages
- Update RPC schemas and preference persistence layer
- Includes readiness validation reports confirming feature is clean
* rm review
* fix documentation
* feat(relay): log the region probe and name the assigned cell
A desktop silently pinned itself to a far relay region for a day and every
phone connect paid the round trip. Nothing in the desktop logs said which
regions were probed, what they measured, why one was rejected, or which cell
the host landed on, so the only way to diagnose it was a bench harness.
The resolver now emits one line per outcome. A refresh carries every region's
probe origins, the discarded warm-up, the kept samples, the minimum, the
spread, and a verdict, then the chosen region or no-hint with the reason it
withheld one. Cache hits, diagnostic overrides, and a director that cannot
list its regions each get their own line so a quiet run is never ambiguous.
Self-heal logs the cached region, the best measured region, the assigned
cell's round trip, and whether it kept or deleted the cache. Only a refresh
reports a catalog failure; a self-heal never chose a region, so a line saying
it withheld a hint would be a lie.
Relay status now carries the assigned cell so the pairing panel can name it.
The field is optional because an offline host holds no assignment and the web
client answers from a stub that never has one.
Splitting catalog fetching out of the preference module keeps both files
inside the line budget without a lint disable.
* fix(relay): drop the assigned cell from statuses not served on it
The origin pool publishes offline while it still holds the assignment it is
about to rotate, so the panel kept naming a cell nothing was served from. The
same class of bug hid a second instance: the coordinator republishes
registered right after the broker announces its cell, and that republish
carried no cell, blanking the value moments after it was set. The cell would
never have reached the panel in the real flow.
Deriving the cell from the status at each publisher removes both. The rule
lives beside the status type because it defines when the optional field is
populated, and the coordinator reads the owned broker's endpoint rather than
trusting a call site to remember to pass it.
* i18n: add the relay cell label to the English catalog
* test(relay): audit the relocated region catalog fetch call site
* fix(relay): report a self-heal whose catalog request failed instead of staying silent