Commit Graph
10636 Commits
Author SHA1 Message Date
Neil ed38308333 test(runtime): pin the removal frame retiring a still-live publisher
KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.
2026-09-10 19:04:27 -07:00
Neil 982d539df8 fix(runtime): publish a terminal retirement proof on the exit's own evidence
A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.
2026-09-10 19:04:08 -07:00
Neil e1a273ece5 fix(runtime): name a terminal whose pane the graph dropped
`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.
2026-09-10 03:06:43 -07:00
Neil e74c22a0e7 fix(i18n): drop the stale TerminalPane.minimumContrast entries from the runtime catalog (main red again) (#19575) 2026-09-10 02:27:49 -07:00
Brennan BensonandMerge Sim 2bf298d1dc feat(native-chat): the background-tasks strip says what is running (#19311)
* feat(native-chat): name, group, and state the background-tasks strip

The strip above the composer described five different kinds of background
work as "Monitoring background tasks", with identical flat-dot rows. Now:

- Wire: additive optional `name`, `state`, `startedAt` on
  AgentSessionBackgroundTask, plus `settledTasks` on the state object so
  terminal siblings of a live fan-out stay visible without changing what
  old clients render (they keep exactly the live `tasks` list).
- Reducer equality learns the new fields, so a publish whose only change
  is a task's state is no longer judged equal and dropped.
- Header counts by kind and lists states within a kind; past three kind
  segments (or on a narrow strip, measured by its own border-box against
  the live root font size) it falls back to an honest total, never a
  partial enumeration, and the strip stays expandable whenever the header
  is lossy.
- Rows group by kind (Agents / Shell / Monitors / Workflows / Tasks),
  stable-sorted first-seen-then-id, each with a kind icon, its own state
  dot, a resolved name (description -> name -> kind label), and elapsed.
- Claude producer: task frames now carry name (agent_type/subagent_type),
  a run state mapped from patch status, and first-seen startedAt. Terminal
  statuses settle a task (completed->done, failed->blocked,
  killed/stopped->idle) instead of deleting it; settled tasks render only
  beside still-live work and flush when the last live task ends, so the
  strip exits exactly when it does today. An unreadable patch leaves a
  task open, never settled.
- Turn gating moves off the strip: the tracker no longer zeroes its
  roster during a foreground turn, and the client renders the strip
  whenever it has contents while the idle-only flag now gates just the
  animated monitoring indicator and conversation commands.

* feat(sidebar): indent native-chat subagents under their session row

buildSubagentChildRows() has always rendered indented children from
parentEntry.subagents, and the structured-session status bridge has
always published an AgentStatusEntry for native chat — it just never
populated subagents. Connect them:

- Wire: additive optional `backgroundTasks` on AgentSessionStatusSummary
  (live tasks only), projected by the host status feed from the
  provider's backgroundTaskState hook and republished on task edges via
  the background-task channel, with the shared task equality suppressing
  no-op re-projections.
- Bridge: maps agent-kind tasks onto the sidebar's own
  AgentSubagentState (working/waiting/blocked, terminal -> idle) — kinds
  stay distinct, so a backgrounded shell never lands in a subagent
  count — and extends its pre-write equality with the existing
  agentSubagentsEqual.
- parentIsFresh for a bridge entry means "the host feed reported a
  change inside the sidebar's ordinary evidence window": every publish
  restamps evidenceObservedAt, and a dead feed stops restamping, so
  children decay to idle on lost contact instead of pinning 'working'.

* fix(native-chat): settle tasks the aggregate roster evicted first; carry usage

Real-agent QA showed settledTasks never rendered. A frame capture from the
SDK (probe against claude 2.1.261) explains it: when a backgrounded child
finishes, the producer emits `background_tasks_changed` FIRST — with the
task already absent — and only then `task_updated`/`task_notification`
with the outcome, in the same tick. The tracker's settle path looked the
task up in the live roster the aggregate had just evicted, so retention
lost the race 100% of the time.

Fix: aggregate eviction of a live backgrounded task now parks its details
in a bounded recently-removed map (new claude-settled-background-tasks.ts,
which also owns the settled roster), and the trailing terminal edge
consumes it. A removal whose outcome frame never arrives still vanishes —
nothing is guessed into a finished state. A second terminal edge for the
same task re-derives the settled state and can add final usage. The
captured sequence is replayed verbatim as a tracker test, including the
kill-at-exit tail proving the strip still exits with the last live task.

The same capture disproved the PR's earlier claim that Claude task frames
carry no usage: task_progress and task_notification both carry
usage.total_tokens. Additive optional `totalTokens` on the wire task,
covered by the shared equality; the tracker takes usage (never the
transient "Running <tool>" description) from task_progress, and rows
render the mock's "18.1k · 2m" meta — settled rows keep final usage with
no still-growing clock.

* chore(i18n): sync runtime-required catalog for backgroundTasks.runningList

* fix(native-chat): preserve background task lifecycle and bound update work

* fix(native-chat): transfer resumed background tasks to one live owner

* fix(native-chat): bring structured session host under the line cap and restore subscribe fixture

* fix(native-chat): complete journal stubs and stop notifying on feed teardown

The status feed's projection cache calls journal.cursor(); the rename test's
stubs are cast through unknown, so the missing method only surfaced at runtime.

Teardown runs only once nothing is activated, so there is no mounted reader to
notify - clearing confirmed sessions is what prevents a stale live on reactivation.

* feat(native-chat): lead each strip header count with its kind icon

The header carried one aggregate state dot, so a fan-out of agents and a
monitor looked alike. Each count segment now leads with its own kind glyph;
a collapsed total spans kinds and takes none.

Monitor is the heartbeat AgentStateDot already draws for monitoring, so the
strip and the agent sidebar speak one vocabulary.

* feat(native-chat): give the strip's monitor heartbeat the sidebar amber

The glyph matched AgentStateDot but the colour did not, so a monitor in the
strip did not read as the monitor in the agent sidebar. One shared tone helper
now serves the header segment and the expanded row, so they cannot diverge.

Monitoring is a state the app already colours; the other four kinds are plain
markers and stay neutral. A running turn still dims the whole set.

* fix(native-chat): draw the strip header separator in a visible tone

The separator used `text-border`, a divider-line token that is 7% white in
dark mode - an order of magnitude fainter than the counts on either side, so
the dot between them read as absent. main.css already records that token as
too faint for a visible mark.

* fix(native-chat): give the worktree-ps journal stub a cursor

The status feed's projection cache calls journal.cursor(); this stub is cast
through unknown, so the missing method only surfaced at runtime. Its journal
never changes, so a real one would hold the cursor steady.

* refactor(native-chat): split the sidebar subagent rows out of this PR

The strip stands alone: the sidebar mapping, its observation plumbing and the
AgentStatusEntry.subagents wiring move to a stacked follow-up. No wire field
here is sidebar-only - the strip's rows read name, state, elapsed and tokens.

* perf(native-chat): keep task usage out of the session status summary

A `task_progress` frame ticks a background task's `totalTokens`, which
failed the status feed's equality check and re-broadcast a full summary to
every `agentSession.subscribeStatus` subscriber — paired-web and SSH/relay
clients included — for a number no session list renders. The projection now
drops usage; tokens keep flowing on the background-task channel the strip
reads.

* fix(native-chat): correct token unit rounding and drop the unused dot state

`formatBackgroundTaskTokens` rounded before choosing the unit, so 999_950
rendered as "1000k" instead of "1m"; pick the unit from the rounded value.

`backgroundTasksDotState` has no caller on this branch or the stacked
sidebar PR, and its multi-kind branch would report 'monitoring' over an
attention state. Delete it rather than leave it to be wired up.

* fix(i18n): drop the orphaned backgroundTasks.runningList key

The strip rewrite removed its only call site, and an unreferenced key gets
promoted into the eagerly parsed boot catalog. Delete it from en.json and
regenerate en-runtime-required.json.

* fix(native-chat): show the reason on every attention row

The row guarded the reason line on 'waiting', so an 'unverifiable' child
("no contact") and a 'blocked' one ("failed") rendered bare while the
collapsed header named exactly those reasons. `backgroundTaskStateReason`
already returns null for the non-attention states, so the guard was only
lossy — the SSH boundary requires the unverifiable verdict stay legible.

Also keys the header segments off their kind discriminant instead of the
translated display text.

* fix(native-chat): make the strip header agree with its own count

The headline counts live AND settled rows, but the state breakdown omitted
'done', so one working agent beside four settled ones read "5 agents — 1
working": the count said five, the breakdown accounted for one. Done now
appears in the muted detail (never as an emphasised segment) so the two
agree.

The single-command header also drew an elapsed clock on a settled task,
which the row already refuses as a lie about finished work.

* perf(native-chat): memoize the background-task roster grouping

The 1 Hz elapsed tick re-rendered the strip, and the render body regrouped,
re-sorted and re-translated every task each time only `now` had changed.
The header still derives from `now` on purpose.

* test(native-chat): cover settled rows and the mid-turn mounted strip

Neither headline behaviour had component coverage: every strip render passed
`settledTasks={[]}`, and the `showBackgroundTasks` seam was never set true,
so the strip staying mounted through a running turn was exercised nowhere.

Adds a settled-beside-live row test (final usage kept, no clock, no stop) and
a mid-turn mount test (strip present, turn owns the voice). The background-task
tests share one session-element helper so the file stays under its line cap.

* refactor(claude): keep MAX_TASK_ID_LENGTH module-private

Nothing outside claude-background-task-frames.ts references it; the export
was residue from this PR's split.

* test(native-chat): give the mid-turn strip test a real turn

main now gates the composer's stop button on a provider-minted turnId rather
than the send-time working signal, so a test claiming a running turn has to
supply one. The controller mock hardcoded turnId null.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 23:47:47 -07:00
github-actions[bot] 4e0aa7a473 Update README downloads badge 2026-09-10 06:40:42 +00:00
Brennan BensonandMerge Sim dda103d2cf fix(native-chat): one / picker for every agent, anywhere in the prompt (#19832)
* fix(native-chat): one `/` picker for every agent, anywhere in the prompt

The composer only opened its picker when `/` was the first character of the
draft, so a skill named mid-sentence ("validate it with /electron") offered
nothing. Codex was worse: its `/` menu listed commands only, and skills lived
on a separate `$` trigger, so the prompt box behaved differently per agent.

`/` is now the whole composer grammar. It opens one grouped commands+skills
menu for every agent with a known grammar, both at the start of the draft and
mid-prompt after whitespace. The `$` trigger is gone.

Per-agent invocation is preserved where it belongs — in what a pick writes.
Each row carries its own token, so choosing a skill in Codex inserts
`$electron` while Claude inserts `/electron`, and the text that reaches the
agent stays the text that agent actually invokes. Only a draft-leading command
is dispatchable; picking one mid-sentence completes the token instead of
sending the command on its own and discarding the draft.

Name collisions now key on whether both kinds share a sigil, so a Codex
`/review` command and a `$review` skill stay separate rows.

* test(native-chat): model dismissal inputs on the live `/` grammar

The trigger-key swap cases still used `$:4` keys. editReplacesTriggerToken is
sigil-agnostic so they passed, but they modelled an input the composer can no
longer produce.

* test(native-chat): pin inline picker dispatch and discovery reuse

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 23:21:57 -07:00
Neil 34790ce084 perf(terminal): share one ESC dispatch table between the two scanners (#19842)
The partial-tail state machine and the preview normalizer's per-sequence
parser each carried their own copy of the byte-after-ESC table, so the
DCS/SOS/PM/APC set was stated twice and could drift.

Move it to `terminal-escape-introducer.ts` and have both read it. The
normalizer now classifies from `charCodeAt` instead of `value[i]`, which
drops the one-char string it minted on every escape it parsed -- the same
allocation the ground scan already avoids.

No behaviour change: `parseAnsiControlSequence` matches its previous
implementation on every 2- and 3-byte sequence after ESC and on 20k
random control-dense streams (differential check, not committed).
2026-09-09 22:14:56 -07:00
Neil 0b60b0dcb1 perf(native-chat): bound retained items on the structured session path (#19841)
`mergeSubmissions` caps submissions at 256, but `mergeItems` had no
equivalent bound, so `state.items` grew for the whole life of a long
structured session while the live path caps itself to its read window.

Head-trim `items` to a retained-item limit when a live batch merges, and
set `hasOlder` so anything trimmed is still reachable by paging. Paging
older raises the limit to what the page produced, so a live batch slides
the widened window instead of collapsing it back to the cap -- the same
shape as the live path's growing `limitRef`.
2026-09-09 22:12:52 -07:00
Neil a067cccd38 perf(terminal): resume OSC terminator search past the carried frame (#19839)
A single unterminated OSC 9999 marker split across many PTY chunks
re-scanned the whole accumulation for a terminator on every chunk, so
work grew with the square of the frame length.

Carry how much of `pending` already failed the search and resume one
character before it, which is enough for an `ESC \\` straddling the
chunk boundary.
2026-09-09 22:06:31 -07:00
Brennan BensonandMerge Sim 4b4acf26a4 fix(mobile): enable patch-free iOS text selection in native chat (#19769)
* fix(mobile): make every native-chat text node selectable

Long-press selection worked on some chat text and not others. Markdown
paragraphs — the default block for agent prose — were the one block type
left out when headings, quotes, code, lists and table cells gained
`selectable`, and tool result output, diff rows, the unloadable-image
placeholder, permission/question bodies and the send-error banner never
had it at all.

Selection is now set on every content Text in the chat surface, on the
outermost block Text so nested inline spans inherit it. Labels inside a
Pressable (option rows, tool-line headers, buttons) are deliberately left
alone: selection there would swallow the tap they exist for.

Extracting MobileNativeChatEmptyState keeps the view under its max-lines
cap and matches desktop, where NativeChatEmptyState is already its own
component.

Tests render each surface and assert selection on the block that carries
the prose; both files were ablated against the unfixed source (4/10 and
3/5 red) so they pin the defect rather than the current behavior.

* fix(mobile): support native text range selection on iOS

* fix(mobile): remove persistent assistant message controls

* fix(mobile): scope patch-free text selection to chat

Use the stock react-native-uitextview dependency behind an iOS adapter and opt assistant Markdown into range selection only in native chat. Preserve the existing React Native Text behavior elsewhere and remove the persistent assistant controls.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 21:50:31 -07:00
Brennan BensonandMerge Sim 2f828e4462 fix(native-chat): show Claude working from the send, not the provider echo (#19822)
* fix(native-chat): show Claude working from the send, not the provider echo

A structured session read as working only once a turnLifecycle row existed.
Codex writes that row ~150ms after the send; Claude cannot write it until the
SDK echoes the user message back, measured at a 3.4s median and 18s at p90, so
the chat and every session list read idle for the whole wait.

The journalled submission is the host's own evidence a turn is owed, so the
shared projection reads it too. `unknown` still counts -- the ack budget
elapsing answers delivery, not whether work is owed -- while a recovered
`unknown` does not, which needed the existing row flag carried onto the
projected submission.

Claude's activity line now stays the generic fallback. Its only turn-wide frame
carries a bare token, and its task_* prose describes a spawned task rather than
this turn; compaction is kept because it explains an otherwise silent wait.

* Fix structured chat pending-work lifecycle and mobile cancellation

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 21:38:35 -07:00
Jinwoo Hong aac38d698f fix(push): isolate deployment and validate candidates before activation (#19771)
* fix(push): isolate deployment and validate candidates before activation

* test(push): classify dedicated rollout outside shared SQL lock census

* test(push): verify independent deployment identity and lock
2026-09-09 16:22:17 -04:00
Jinwoo Hong 7dd183d82d fix(native-chat): keep worktree active during chat creation (#19753)
* fix(native-chat): activate worktree before chat session

* test: update native chat activation census
2026-09-09 13:47:59 -04:00
Jinjing 73d0521410 Replace the sidebar create dropdown with two direct action buttons (#19653) 2026-09-09 10:42:18 -07:00
github-actions[bot] b94bcdc632 Update README downloads badge 2026-09-09 12:37:29 +00:00
ed9d76178d perf: check tunnel queue capacity before copying frame payloads (#19497)
* 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>
2026-09-09 03:59:50 -07:00
Neil 946b1fc078 perf(terminal): own retained tail rows so they stop pinning their chunks (#19528)
Every row in the 2000-row retained tail is sliced from the PTY chunk it
arrived in, so a spinner workload — CR-redraw frames, exactly what Claude
Code and Codex emit — makes a ~5 KB tail pin one whole chunk per row.
Measured on 400 x 64 Ki-char chunks: a 5,090-char tail retained 37.5 MB.
At 16 Ki x 1600 it was 46.9 MB.

Own the two values the plain tail path actually retains, newlyCompletedLines
and the partial line, at the point they enter the tail: 37.5 MB -> 0.18 MB
and 46.9 MB -> 0.09 MB, with per-chunk cost unchanged within noise. The
transcript keeps the same row objects, so it is covered transitively.

The multiline redraw builder never slices normalizedChunk — it writes rows
character by character — so only its partial line, which is re-sliced from
its own row on every frame, needs owning. Owning its rows as well measured
no retention gain and cost CPU on fullscreen TUI floods.

Also own the wait-blocked keywordCarry: 31 characters that pinned a full
lowercased copy of the chunk, per PTY.

Follow-up to #19396, which introduced ownRetainedString for the much smaller
pending-control fragments.
2026-09-09 03:50:45 -07:00
Neil 373a670aba perf(terminal): skip ordinary output in preview control scans (#19400)
* perf(terminal): skip ordinary output in preview control scans

* test(terminal): cover scan resumption after parsed controls
2026-09-09 03:49:14 -07:00
Neil 1e2ed20b35 perf(terminal): release oversized backing strings behind pending controls (#19396)
* 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.
2026-09-09 03:40:03 -07:00
Neil 042cc5266c perf(terminal): skip plain text between partial escape sequences (#19393)
* 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.
2026-09-09 03:31:17 -07:00
91f00b32ef perf: stop merging OS-opened files at the pending queue cap (#19508)
* perf: stop merging OS-opened files at the pending queue cap

* fix(os-open): report markdown opens dropped at the pending queue cap

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-09 03:29:51 -07:00
Neil 8c18e42be2 test(ci): replace fixed teardown waits with bounded polls (#19720)
The two flakiest tests on main both guessed at a duration instead of
waiting for the condition.

- windows-pty-job.win32.test.ts assumed job teardown finished in 1.5s;
  under load on a Windows runner it does not. Poll isAlive up to 30s
  instead -- the assertion is unchanged, so a real leak still fails.
- structured-agent-session-claude-options-round-trip.test.ts relied on
  vi.waitFor's 1s default for a two-hop handoff; give it 10s.

Both are test-only and strictly widen an existing wait.
2026-09-09 03:28:29 -07:00
069bdae283 perf: materialize only the requested recent plugin audit lines (#19498)
* perf: materialize only the requested recent plugin audit lines

* test(plugins): prove the recent audit window matches the full-split selection

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-09 03:23:55 -07:00
f1f2d61d0e perf: resolve current-workspace document addresses before catalog scans (#19495)
* perf: resolve current-workspace document addresses before catalog scans

* test(browser): lock current-workspace precedence in doc address resolution

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-09 03:23:47 -07:00
bf1e1b9004 perf: probe requested pane keys instead of enumerating records (#19494)
* perf: probe requested pane keys instead of enumerating records

* perf(agent-status): drop the requested-key array from pane removal

Probing the pane keys still beat enumerating the record, but materializing
the requested set allocated on every call including the common no-match
path, where a dozen records are swept per retirement. Copy lazily on first
match instead, and cover the set-disagreement and prototype-key cases.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-09 03:23:38 -07:00
Neil 23f38ddf7f perf(workspaces): skip unchanged heartbeat subscriber work (#19392)
* perf(workspaces): reuse unchanged heartbeat status projections

* perf(workspaces): skip unchanged title-sync session collections

* test(workspaces): cover constant-size key swaps in status projection reuse

Also point the title-input gate at a tracked source file; the previous
motivatingLink referenced an untracked .agents skill path.
2026-09-09 03:19:55 -07:00
Neil db66ab4ac8 perf(runtime): skip hibernation inventories without completed agents (#19391)
* perf(runtime): skip hibernation inventories without completed agents

* perf(runtime): skip hibernation status scan when no runtime owners

* fix(runtime): require host evidence for workspaces resolved mid-inventory

`runtimeLivenessRequiredWorktreeIds` was sampled before the runtime inventory
await, while the plan is built from the state after it. A workspace that gained
tabs or resolved its runtime owner during that window was therefore absent from
the required set, so the planner did not demand fresh host evidence for it and
fell back to client PTYs — client bookkeeping answering for the execution host.

Union the post-await targets into the required set inside `snapshotFromState`.
Union rather than replace: the set only ever grows, so the planner can only skip
more workspaces, never authorize a hibernation it would previously have refused.
An absent inventory stays a skip; nothing reads it as an exited PTY.

Extract the coordinator test fixtures so the regression lives in its own file
without pushing the coordinator suite past the 800-line test cap.

* fix(types): annotate hibernation fixture mock exports for declaration emit

TS2883: the inferred `Mock<Procedure>` types of the fixture's exported
`vi.fn()` bindings reference `Procedure` from a transitive `@vitest/spy`
path that cannot be named.

* chore: keep local-file-sink-memory test formatting as on main

The merge commit's pre-commit hook reformatted a file this branch does not own.
2026-09-09 03:17:20 -07:00
OrcaWinandm4air c875941d7c perf: stop queued metadata work after watcher cancellation (#19446)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-09 03:17:15 -07:00
Jinwoo Hong 65631e449a refactor(ai-vault): split the session scanner into a transcript reader and consumers (#19666)
* refactor(ai-vault): split the session scanner into a transcript reader and consumers

The scanner's only output was the Session History summary; a second reader
of the same transcripts (a search index) had nowhere to plug in without
hooking the parse itself. Extract a reader that owns each file read, keeps
the resumable cursor and publishes every decoded message to registered
consumers. The session list stays a fold inside the parser and is the only
consumer here. Parsers take an optional message sink instead of a scope.

Also: read Cursor chats/<md5>/<uuid>/meta.json for cwd, title and
timestamps (Cursor transcripts carry only role and message); share the
lazily spawned worker-thread host between the OpenCode SQLite reader and
the port-scan probe; probe OpenCode's schema before querying; keep the
newest-N discovery set with a bounded insert instead of sort+slice.

Session list output is byte-identical to main across all 18 providers
cold and append-resumed; the one Cursor session gains cwd/timestamps
from meta.json.

* fix(ai-vault): serialize per-path parses and report unpublished reads

Overlapping parses of one transcript share the cached resume point's
message channel, so the second beginRead dropped the first read's
consumers and the first finishRead handed them the wrong outcome. Two
callers really do overlap: a forced refresh restarts a scan while the
aborted scan's parse is still in flight, and the title reader parses
outside any scan. Restore the per-path lane around the whole
lookup-read-store sequence.

OpenCode's SQLite sessions are decoded on a worker thread the channel
cannot reach, so their reads published no messages while reporting a
complete span. Finish those reads as incomplete instead, so a consumer
never records a cursor for a stream it did not receive.

* fix(ai-vault): degrade a refused cursor chats read instead of dropping sessions

A refused WSL read of Cursor's chats tree rethrew, and the per-file catch
in discovery then recorded an issue and skipped the transcript. Before
the meta.json join Cursor had no content dependency, so a stalled distro
could not hide a Cursor session at all. Degrade to no metadata for the
scan and report the chats root once. The parse cache stays honest without
the throw: discovery stats no meta.json on a refused scan, so the entry's
recorded size omits it and the next healthy scan re-reads the transcript.

The per-scan index scope covered discovery only, so every Cursor finalize
re-read the chats root to validate the module cache. Move the scope to
scanAiVaultSessions, which spans discovery and parse.

Also drop the unused signal parameters the sink threading added to the
Devin and Hermes content parsers, by giving each file parser a private
record parser instead.

* fix(ai-vault): do not cache a cursor parse whose meta.json read was refused

Discovery stats meta.json into the candidate's cache key, so when only
the meta.json read is refused the un-enriched session was stored under a
key that looks unchanged and reuseCachedSession never re-ran the enrich
hook. The session stayed without cwd until Cursor rewrote the file. The
enrich hook now reports 'refused', the resumable state exposes
isCacheable, and the parse cache drops the entry instead of storing it,
so the next healthy scan re-parses. The index-read branch is unaffected:
it never stats meta.json, so its key is honest already.

* fix(ai-vault): separate the transcript's size from its cache key

sizeBytes folds a content dependency's size in, so it is a cache key
rather than a file length. The reader compared a transcript byte offset
against it and reported it as a whole-file read offset, which for Cline
handed consumers an offset past the end of the file it read. Carry the
dependency's own size on FileWithMtime and subtract it in the reader.

A refused sibling stat rethrew, so discovery recorded an issue and
skipped the transcript, the same drop removed for the readdir and read
paths. Degrade to no dependency, note the tree once, and mark the key
untrustworthy.

An untrustworthy key no longer costs the resume cursor: the entry is
stored under an mtime no stat can produce, so unchanged is false while
the resume point survives and the next scan resumes instead of re-reading
the whole transcript.

* test(ai-vault): pin the untrustworthy-key mechanism, not just its effect

Both refusal tests asserted that a later healthy scan re-enriches, which
a plain store would also satisfy once the resume cursor was preserved.
Assert the cache entry directly: its mtime is the unmatchable sentinel
and its resume point survives. The sentinel is exported so the tests name
the contract instead of repeating -1.

* refactor(ai-vault): track a session's sidecar file apart from its transcript

Folding Cursor's meta.json stat into the transcript's mtime/size made one
key mean two things, and every round of review found another consequence:
a byte offset could not be compared against it, a refused sibling read
took the transcript down with it, and an un-enriched parse cached under
it looked current forever. Main already had the answer for a file the
transcript key cannot see: Codex titles are refreshed at reuse time over
the cached session, not folded into the key.

Discovery now records the sibling as its own observation, unknown when it
could not be read. A cache hit needs both the transcript key and the
sidecar to match. When only the sidecar moved, Cursor re-merges it over
the stored un-enriched fold result and never re-reads the transcript;
Cline, which reads its sibling as part of the parse, re-parses.

Merging over the fold result rather than the accumulator makes enrichment
pure, so a meta.json rewritten with a new cwd replaces the old one
instead of losing to it. That was unreachable while the merge used ??= on
a session it had already enriched.

Cline and the remote scanner move to the same field, so the fold is gone
from both discovery paths.

* fix(ai-vault): tell an absent sidecar from an unreadable one

Three places collapsed the two. sidecarUnchanged returned true for any
observed 'none' without reading the entry, so a sidecar that was deleted,
or one that was unreadable last scan, both read as cache hits. Native
discovery mapped every non-WSL stat failure to 'none', so an EACCES on
meta.json left a session enriched from a file nobody can see, with no
scan issue. Remote discovery could not tell a missing sibling from a
failed stat, because statRemoteSessionFile returns null for both.

'none' is now a claim: absent-now is a hit only when it was absent before
or the agent never had a sidecar, and only ENOENT/ENOTDIR reads as
absent. statRemoteSessionFile grows an opt-in rethrow so its caller can
distinguish the two failures it already reports.

Also rewrites three comments in the cursor chat-meta reader that still
described the deleted fold.
2026-09-09 03:36:41 -04:00
Brennan BensonandMerge Sim acd501486d Unify tab surface selection across workspace activation (#19635)
* Unify tab surface selection across workspace activation

* Cover the folder activation entry point and name its selection contract

Rewrite the folder-workspace selection tests to drive setActiveFolderWorkspace,
the entry point this PR rewrote; they previously went through setActiveWorktree
and exercised the git-worktree projection instead, so none of them failed
against pre-PR code. Add the layout-only ownership case.

Hoist the remembered-file condition out of a three-deep nested ternary and pin
the remembered agent-session/simulator cases that make it load-bearing, and
replace the Parameters<typeof ...> indirection with a named
ActiveSurfaceSourceState.

* Pin the folder-path openFiles fallback

The folder path now reaches the shared openFiles fallback: with no groups, no
layout and the remembered browser tab gone, an open file selects the editor
surface instead of falling through to terminal. That is parity with the
long-shipped git path, and nothing covered it.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 00:17:27 -07:00
Brennan BensonandMerge Sim 5868fdc9e3 feat(native-chat): report Codex background tasks in the chat strip (#19346)
* 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>
2026-09-09 00:13:20 -07:00
Jinwoo Hong 0fe132ea29 fix(orchestration): file mail from terminals in no Run under an unbound Run (#19696)
* 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
2026-09-09 03:06:38 -04:00
Jinwoo Hong 2ee4053ae6 fix(native-chat): merge duplicate native-chat-types import (#19698)
#19230 added a second import of the same module, and the focused
code-quality gate (import/no-duplicates, --deny-warnings) fails every PR
opened on main since it merged.

Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9
2026-09-09 03:05:10 -04:00
Brennan BensonandMerge Sim 7197593e31 fix(lint): preserve deliberate collator benchmark baselines (#19686)
Co-authored-by: Merge Sim <sim@local>
2026-09-08 23:53:17 -07:00
Brennan BensonandMerge Sim d15a6df224 Render task checklists with update diffs and a composer progress panel (#19230)
* Render native chat task lists with incremental checklist updates

* Keep native chat task-list review plan out of repository root

* Render live Codex plan notifications through task checklists

* fix(native-chat): keep checklist test fixture within shared boundary

* Keep agent task progress in one composer panel

* Restore inline task checklists and historical update diffs

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 23:50:32 -07:00
Brennan BensonandMerge Sim addd9f3da7 fix(orchestration): accept a missing Run id from older federation coordinators (#19689)
* fix(orchestration): accept v1.4.198 coordinators on federationAttachStart

#19542 made runId required on the attach RPC and backfilled existing
attachments with '', on the premise that federation was unreleased. It
shipped in v1.4.198, so a v1.4.198 coordinator got 'Missing Run ID' from
an upgraded worker host, and every pre-upgrade attachment lost its mailbox
because home_run_id='' matches no Run.

- runId is optional on the wire; an absent id mints a per-attachment stub
  Run (run_federated_<dispatch>) through the same INSERT OR IGNORE path.
- migrate-v40 backfills existing attachments with the stub and inserts the
  stub Runs, so in-flight workers keep reporting back.
- create SQL gives home_run_id a DEFAULT '' so a rolled-back v1.4.198 host
  can still insert into a v1.4.199-created table.

Stub Runs never carry run_legacy_local, and #19542's attachment-mailbox
exclusion in the skew probe is untouched, so no adoption replay path is
reintroduced (probe test added).

* fix(orchestration): repair empty federated home Run ids on every open

A host on v1.4.199 that rolls back to v1.4.198, attaches workers (rows
land with home_run_id='' via the DEFAULT), then upgrades again never
re-runs the v40 backfill because user_version is already 40, so those
attachments stay without a Run and their control mail is refused.

Lift the two idempotent set-based statements out of migrate-v40 into
backfillFederatedStubHomeRuns and run it from both the v40 migration and
the OrchestrationDb constructor after migrate(), matching the existing
on-open rememberCurrentRunCoordinatorHandles repair.

Tests: reopen a v40 file DB holding a v1.4.198-shaped '' row through the
constructor and assert the stub Run and mailbox; pin the wire schema
accepting a v1.4.198 request with no runId ('' -> undefined, whitespace
passes the schema and is refused at the DB layer).

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 23:41:46 -07:00
Brennan BensonandMerge Sim 750e6ffada test(orchestration): pin the Run-required contract for unbound direct mail (#19684)
* test(orchestration): pin the Run-required contract for unbound direct mail

* test(orchestration): pin absent recovery keys and settle the push window for unbound mail

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 23:02:05 -07:00
Brennan BensonandMerge Sim 3a801d213d fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance (#19423)
* fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance

A dispatched PTY worker whose agent wedged at startup (six codex workers on
2026-09-07) was reported 'ok: true, state: ready, stage: input_accepted': the
preamble write was acknowledged with observationTimeoutMs: 0 and nothing ever
verified a turn began. The corpse and the healthy worker produced identical
receipts.

worker-start now runs the existing second-stage prompt observer
(observeTerminalAgentPrompt) after acceptance, inside the 30s window the
client RPC grace already budgets for (orchestration-worker-start-prompt-budget):

- turn observed (or provider ack for structured sessions) -> ready
- permission prompt -> ready; positive liveness, surfaced in the receipt
- provider without a turn-start signal -> ready; observation: unsupported
- observation supported and nothing started -> worker state start_unknown,
  response state outcome_unknown with nextCommands. Honest 'unverifiable',
  never a death claim: the capability and terminal are kept, and
  worker-report settlement already reconnects a start_unknown worker that
  recovers and reports.

Also fixes the effect-verb lie that misdirected the first diagnosis of this
incident: agent-first worktree creation labeled its own brand-new agent
terminal 'reused_agent_terminal' (a role test picking a lifecycle verb) on
both the local and federation paths. It now says 'created'; readers keep
accepting the retired verb for rows persisted before the rename.

* fix: preserve worker authority through start observation

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 22:53:48 -07:00
Brennan BensonandMerge Sim fe9d538b6f fix(native-chat): keep collapsible chevrons beside their header text (#19656)
Co-authored-by: Merge Sim <sim@local>
2026-09-08 22:45:30 -07:00
Brennan BensonandMerge Sim 210ac1f77c fix(native-chat): expand Claude slash commands sent with an attachment (#19654)
* fix(native-chat): keep the text block last in Claude dispatch content

Claude reads a streamed user message as a slash-command invocation only when
the last content block is text. The composer builds the send body as
text-then-images, and the Claude dispatch mapper preserved that order, so any
slash command sent with an attachment arrived as literal prose instead of
running -- /review, /init and the rest, not just one command.

Emit images ahead of text in the mapper. Images-before-text is also the order
Claude prefers for vision, so this is unconditional rather than slash-only.

Fixed in the Claude mapper rather than the shared send body: that body also
defines rendered journal block order for every provider.

* fix(native-chat): derive Claude slash-command recognition from the sent prompt

Claude encodes a user turn as attachment blocks followed by the typed text and
recovers the prompt by reading only the trailing text block, so a body ending in
an image has no recoverable prompt and its `/command` reaches the model as prose.
The composer builds text-then-images, so that was every send with an attachment.

Emit one trailing text block rather than appending each block: a partitioned
`[...images, ...texts]` still strands a command ahead of trailing prose, because
only the last block is read. Derive `acceptsResult` from that same sent content
so the mapper and the dispatch waiter cannot disagree about whether a command
ran; the previous `blocks.some(trimStart)` was strictly more permissive than
Claude, which does not trim before matching `/`.

* test(native-chat): pin the sent Claude content order and the joined prompt verdict

The attachment scenario passed unchanged against main: it only exercised
acceptsResult, which was already true there. Assert the send payload, and
pin that joining narrows a command hiding behind leading prose.

Reuse claudeRecord for the content-key narrowing the same file now imports.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 21:52:25 -07:00
Brennan BensonandMerge Sim 1c1cb7115a fix(orchestration): unify structured-session status across worktree ps and sidebar (#19217)
* 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>
2026-09-08 21:50:51 -07:00
Brennan BensonandMerge Sim 72befaf360 feat(native-chat): show Claude subagent activity on the shared carrier (#18806)
* 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>
2026-09-08 21:44:10 -07:00
Brennan BensonandMerge Sim 852495d35c fix(native-chat): unify launch routing and support structured worker placement (#19431)
* 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>
2026-09-08 21:40:23 -07:00
98df62d8ff perf: reuse normalized task-page repository selection (#19454)
* perf: reuse normalized task-page repository selection

* test(task-page): cover ineligible persisted repo IDs in initial selection

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:55:25 -07:00
eedaf2bdfc perf: check backfill date cardinality before expanding ranges (#19456)
* perf: check backfill date cardinality before expanding ranges

* test(codex): pin the backfill cardinality gate to the enumerated range

Differential coverage at maxDates === length and length - 1 across leap days,
century rules, year rollover and DST switch dates.

* test(codex): type the backfill cardinality table as date tuples

Untyped it.each rows widen to string[], which tsc rejects when cast to the
3-tuple CodexSessionBackfillDate.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-08 19:53:24 -07:00
2558bf1b17 perf: count command-line escapes without regex match arrays (#19499)
* 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>
2026-09-08 19:46:30 -07:00
6d2d63104a perf: reuse naturally ordered unique Codex trust ranges (#19492)
* perf: reuse naturally ordered unique Codex trust ranges

* test(codex): pin the trust-range ordering the dedup removal relies on

Removing the pairwise dedup+sort is only sound while the scanner emits
strictly ascending, non-overlapping spans. Guard that precondition so a
future scanner change cannot silently widen or drop a trust block.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:44:31 -07:00
e373c89536 perf: cache update timestamps for Linear and Jira result sorting (#19473)
* 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>
2026-09-08 19:44:13 -07:00
5f63b850a5 perf: select checks-panel workspace attribution in one pass (#19471)
* perf: select checks-panel workspace attribution in one pass

* perf(checks-panel): normalize candidate paths only after the cwd filter

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
2026-09-08 19:43:59 -07:00