Commit Graph
2042 Commits
Author SHA1 Message Date
Brennan BensonandMerge Sim 25b5fac68a feat(native-chat): record the provider's name for an agent session (#19908)
* refactor(ai-vault): move the surrogate-safe slice to shared, one implementation

`sliceAtCodeUnitLimit` lived in src/main/ai-vault, and src/shared never imports
src/main, so a shared consumer could not reach it. Rather than add a second
copy, it moves to src/shared and ai-vault imports and re-exports it, leaving
every existing importer of that module untouched.

Separated from the feature that needs it: this is the only change here to a
subsystem the rest of the branch does not touch.

* feat(native-chat): give an agent session one place to hold its name

Adds the normalized conversation name and the record field that stores it, so
Orca has a durable note that it already named a session and does not name it
again on a later acquisition. No name is generated yet, and nothing displays
this field: the AI Vault path stays the home for the name a user sees.

- `agent-session-conversation-name` bounds and flattens the text once, at 200
  characters, cutting on a character boundary.
- `AgentSessionRecord.conversationName` carries it, validated on load.
- `setAgentSessionRecordConversationName` sets or clears it, unfenced: the name
  is a durable note, not ownership, so writing it never contends with the
  writer lease.

* refactor(runtime): move reserve-owner orchestration next to its admission logic

Pure move, no behavior change. `reserveOwner`'s transaction body sequenced
decisions that all live in agent-session-reservation-admission and then applied
the winning one; it now sits there as `commitAgentSessionReservation`, and the
store keeps the transaction boundary and a one-line delegation.

Takes agent-session-record-store.ts from 300/300 to 279/300, which is what the
conversation-name field needs to land. Every existing test passes unedited.

The module header said nothing in it mutates; that is now qualified rather than
left false, since the committer writes the state it is handed.

* feat(runtime): let the store set a session's conversation name

`setConversationName` is the one writer for the record field, so a producer
never reaches the record shape itself. It normalizes at the boundary, so no
caller can persist a name the loader would then reject as unreadable.

Unfenced by design: the name is durable memory that Orca already named this
session, not ownership, so naming never contends with the writer lease.

No name generation and no provider code yet.

* refactor(runtime): reuse the shared default chat label on a replacement tab

The replacement tab open-coded `'Claude Chat' : 'Codex Chat'` while every other
publisher already calls `defaultAgentChatLabel`. One writer for the placeholder.

* fix(native-chat): reject noncanonical stored conversation names

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 19:15:44 -07:00
Brennan BensonandMerge Sim b0505f0418 fix(sidebar): let an agent row show the provider title (#19936)
* fix(sidebar): let an agent row show the provider's own session title

The row resolver never read `aiVaultTitle`, so a terminal agent's tab showed the
title from its transcript while its sidebar row showed the scraped live title —
two names for one session. Placed at the same rank the tab strip uses.

* fix(sidebar): scope provider titles to their sessions

* fix(dashboard): refresh retained agent tab metadata

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 17:28:04 -07:00
Jinwoo Hong 74cc9b5039 feat(desktop): native mobile push integration (2/3) (#19935)
* feat(desktop): integrate native mobile push delivery and lifecycle

* fix(desktop): preserve notification replay policy and review invariants

* fix(desktop): correct notification locale namespace and auto-ack tests
2026-09-10 20:16:56 -04:00
Brennan BensonandMerge Sim 027acb4efa fix(native-chat): settle a structured send on admission, not on the provider echo (#19863)
* fix(native-chat): settle a structured send on admission, not on the provider echo

Sending a message in structured native chat raised "Message delivery is
unconfirmed." with a Retry button on a message that had in fact been
delivered. Measured across 14 days of local journals: 44 of 173 delivered
sends (25.4%) tripped it.

The dispatch path wrote the message to the provider, then waited a fixed
10s for the provider to echo the message's uuid back. That echo is emitted
when the provider STARTS the turn, so a message queued behind a running
turn cannot be echoed until that turn ends. Echo latency is bounded by the
previous turn's duration, which is unbounded -- one send took 105 minutes.
The 10s constant sat at the p75 of real echo latency, with the slowest
clean send at 9.76s, a margin of 0.24s. No constant can work: the wait was
measuring the wrong event.

The false banner was not cosmetic. It invited a Retry, and Retry bypassed
the operation ledger to redeliver. One message reached the model five times
through that path.

Dispatch now returns as soon as the transport write completes and writes no
dispatch row; the submission stays `pending`, a neutral state, and the
provider's echo settles it `accepted` through the late-settlement channel
whenever the turn ahead of it ends. Delivery doubt is reachable only from
process facts -- a refused write, a dead child, a dead host -- never from
elapsed time.

Retry re-delivers only where the recorded reason proves the message never
reached the provider. The list is deliberately fail-closed: refusing a
legitimate retry costs the user a re-type, while allowing an illegitimate
one sends the model a second copy of their message. A refused entry now
leaves the outbox with an explicit notice instead of parking at the head,
where it would have wedged every message queued behind it.

The send-response classification moves to a pure module beside the existing
outbox reconciler, so both writers of an entry's state now live together and
the decision is unit-testable rather than reachable only through the hook.

Scope and known gaps:
- Codex carries the same 10s stopwatch. It has no late-settlement channel,
  matches waiters by queue order rather than identity, and has no waiter
  lifecycle at all, so there was no safe subset to land here. A marker
  constant records the debt and deletes itself when that lands.
- A message refused re-delivery loses its standing delivery notice and
  leaves only a transient error line. A passive "waiting to be accepted"
  affordance is the follow-up.
- The restart reconciler that would decide a dead child or a dead host on
  evidence rather than refusing them is fully written and has never had a
  production caller. Wiring it is the next change, and it removes the
  re-type cost above.

* fix(native-chat): harden structured dispatch settlement

* fix(native-chat): preserve dispatch recovery evidence

* fix(native-chat): preserve pending send compatibility

* fix(native-chat): satisfy native import audit

* fix(native-chat): bound legacy send settlement

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 16:29:02 -07:00
Brennan BensonandMerge Sim 33436c30d8 refactor(native-chat): unify agent session launch and open drafts in structured chat (#19681)
* wip(native-chat): first-pass draft routing into structured chat (to be reworked)

* refactor(native-chat): gather agent launch route inputs in one builder

Every launch entrypoint assembled the route resolver's inputs by hand and
they disagreed: only three of seven passed the project runtime blocker, so
a WSL-pinned project was refused structured chat from the tab bar but
admitted from the create dialogs. buildAgentLaunchRouteInput is now the
one place that gathers host, capabilities, workspace kind, project runtime
and TUI customization, and works for workspaces that do not exist yet.

Also deletes the dead draft-prompt blocker from the shared resolver; the
renderer stopped passing it and the main process never did.

* refactor(native-chat): share one structured launch settle loop

Five entrypoints copied the same loop around startStructuredAgentLaunch:
start, claim a refusal fallback, await, branch on refusal or unknown. The
copies drifted: direct work-item and full create reported an unexpected
launch error as success, and resume handled neither refusal nor unknown.

settleStructuredAgentLaunch now owns that loop and returns one settlement
(structured, refused-then-legacy, cancelled, visibility-unknown, failed).
Direct work-item, full create, folder workspace, both onboarding folder
paths and vault resume consume it; each keeps only its own legacy fallback.
Resume deliberately has no fallback. Unknown outcomes release the caller
uniformly so a stale fallback closure cannot fire on a later reconcile.

* refactor(native-chat): route the new-tab launcher through the shared settle loop

The new-tab launcher fired its refusal fallback and forgot it: nobody
learned whether the terminal fallback ran, and a visibility-unknown outcome
was never surfaced. Its structured branch now runs through
settleStructuredAgentLaunch with the terminal launch as the legacy fallback.
launchAgentInNewTab stays synchronous; the result gains a structuredSettlement
promise, and promptDeliveryResult keeps following the terminal fallback's
delivery on refusal as it did through the callers bridge before.

* refactor(native-chat): one legacy prompt delivery path and one trust preflight

The direct work-item flow kept its own seed-and-paste copy of the legacy
prompt delivery; it now uses deliverLaunchPromptToAgentTab with its own
timeout notice supplied as a callback. Three private copies of the trust
preflight (session continuation, worktree creation, folder workspace) fold
onto preflightAgentTrust. The direct work-item pre-launch mark keeps its own
entry because it differs in timing, not mechanism.

* refactor(native-chat): run quick create through the shared settle loop

Quick create was the last entrypoint driving the launch handle itself,
because its cancel lifecycle is real: when the creation is abandoned the
structured launch must be cancelled immediately so a staged prompt never
reaches the provider. The shared loop now takes a cancellation hook with an
eager subscription plus a post-await check; it cancels the launch once,
unsubscribes on settle, and reports cancelled without running the fallback.
Quick create keeps its two-branch legacy fallback and retire-on-late-cancel.

Also updates the surface-caller census for the onboarding launch module
that step 2 introduced.

* fix(native-chat): open editable drafts in structured chat for eligible local Codex launches

Route order asked the default-view-mode question first, and that decider
applies the terminal mirror gate (a TUI cannot clear more than forty lines
of prefilled draft), so a PR body over forty lines reached the plain
terminal before structured eligibility was checked. Structured eligibility
now comes first; the mirror gate applies only on the legacy branch.

The structured draft seed writes the launch-draft store directly with no
mirror gate, since a structured session has no terminal copy to fall back
on. Closing a settled structured tab clears an unadopted seed. The
structured session treats idle and loading as unsettled so the adoption
hook takes its baseline from the loaded transcript. Each caller passes one
delivery-mode value to both the route builder and the settle loop.

The structured session component test is split with a shared harness so
it stays under the test file line cap.

* test(native-chat): make the structured session test harness type-portable

* fix(native-chat): close review gaps in the shared launch settle loop

- Claim a refusal fallback only when the caller supplies one, so vault
  resume no longer reports a terminal fallback it never opened.
- A failed or cancelled direct work-item launch returns no tab id, so the
  caller never pastes the prompt into a setup shell.
- Terminal fork activates with providesInitialSurface for structured
  launches and gates its toast on the settlement; the draft blocker
  deletion made fork route structured too.
- A failed launch clears its draft seed. The failure toast moves to its own
  module to keep the launch-state file under the line cap.
- Ratchet for settle-loop callers; cancel-during-fallback documented.
- Restore the local agent label lookup that the pane-agent identity
  inventory expects instead of the inventoried helper.

* fix(native-chat): resolve the agent label through one module

* fix(terminal-pane): keep the fork dialog from reopening a created worktree

A failed or unknown structured settlement returned false after the fork
worktree already existed, so the dialog stayed open and a second click
created another worktree. Unknown now closes the dialog (the launch badge
already reports it); failed copies the context the way a null launch does.

* chore: restore pnpm-lock.yaml to main (local pnpm rewrite slipped into a commit)

* test(native-chat): stop asserting the deleted draft feasibility input

The routing-authority test expected the shared predicate to receive
isDraftPrompt; delivery mode is prompt metadata and never reaches
feasibility now, so assert its absence instead.

* refactor(native-chat): decide every agent launch route in one planner

The route was still resolved at seven callers, each also calling the settle
loop; two census tests only stopped an eighth. planAgentSessionLaunch is now
the one production caller of the resolver and its launch() the one caller of
the settle loop, and both censuses pin exactly that file.

The funnel is two-phase because three sites need the route before the
workspace exists and quick create persists its request for recovery: a plan
exposes route before creation and launches with the created worktree id;
a persisted quick-create request carries the verdict as data and re-enters
through adoptAgentSessionLaunchVerdict without re-resolving. Delivery mode
is fixed on the request once, so route and launch cannot disagree.

* test(native-chat): pin the two adopters of a planned launch verdict

* fix(native-chat): answer route readability from the repo when the worktree row is absent

The planner's transcript-readability input dropped the repo-level connection
fallback the direct work-item path still computes for its startup payload, so a
route planned in the window right after workspace creation saw `undefined` —
which reads as "not locally readable" — and downgraded grok/omp launches from
native chat to a raw terminal. Only `undefined` ("cannot determine the host")
now defers to the repo; a resolved `null` stays the local answer.

* refactor(native-chat): answer structured feasibility with a query, not a launch plan

Every rendered AI Vault row built a whole launch plan — execution-host lookup,
project-runtime resolution, capability read, plus a plan object and a launch
closure it threw away — to read one boolean off it. Feasibility and a launch
decision are different operations, so the planner now exports the predicate for
the first and keeps the plan for the second, and the census pins the query's
callers separately. Settings arrive by argument, which makes the AI Vault
callback's dependency on them real rather than a comment the linter contradicts.

The plan's `explicitStructured` branch had that gate as its only caller and goes
with it; the vault's launch already re-enters on an adopted verdict.

* refactor(terminal-pane): fold the fork's trust preflight onto the canonical one

`preflightForkAgentTrust` was a behavioural duplicate of `preflightAgentTrust`,
whose signature now accepts a nullable agent and workspace path and so is a
drop-in replacement. Its file is left holding only the launch-platform resolver
— which is not a duplicate, since it returns an override rather than a default —
so the file is renamed for what it now contains.

* refactor(native-chat): cancel a structured launch through an AbortSignal

The settle loop's launch cancellation re-derived the standard poll-plus-eager-
event primitive that `AbortSignal` already is, so it now takes one. The eager
semantics are unchanged: the loop still cancels on the abort event rather than
only polling after awaits, so a staged prompt is discarded before it reaches the
provider, and it drops its listener on settle instead of leaving the signal
holding the closure. Quick create owns the controller and bridges its store
subscription to it.

A cancel that lands after the refusal fallback already opened a terminal now
carries that surface on the settlement. It is the fallback's tab that exists, so
reporting the pre-launch one handed the caller a workspace with no agent in it.

* fix(native-chat): tighten quick create's structured launch settle path

Four things the launch path got wrong once the settle loop owned the flow:

- The abandoned-creation check now runs before the first-message rename flag is
  written, so a creation being torn down is no longer marked for a rename that
  will never happen (the order the pre-planner code had).
- A cancel that arrives after the refusal fallback opened its terminal reports
  that terminal rather than the pre-launch tab.
- `plan.launch` is called outside the caller's try, and nothing awaits that
  caller, so a throw there would strand the creation panel. It is now caught and
  reported the way a failed launch already is.
- The launch route is a required argument instead of defaulting to
  `terminal-tui`, which would have silently reported success with no surface
  opened. Both callers already gate on the structured route.

* fix(native-chat): give one launch identity one prompt delivery mode

A caller joining a pending launch computed its outbox text from its own delivery
mode, so an auto-submit caller landing on a draft launch enqueued text the first
caller's seed was already showing in the composer: the user saw it and it was
sent. The mode is now fixed by the caller that opened the launch, and a joiner
delivers its text that way.

Seeding also moved to where the coalesce decision is made, so a launch whose
callers already settled as refused is not given a fresh draft — the refusal path
early-returns, so nothing would ever clear it and it would outlive every tab.

* fix(work-item): report a failed structured launch as a failed direct launch

`launchWorkItemDirect` returned true unconditionally, so a structured launch
that opened no surface still read as a started workspace. Callers hang
irreversible follow-up work off that boolean — the fix-checks dialog fires
`onLaunched` on it, which is documented as the home for host writes — so a
launch with no agent tab now reports false, matching what full create does.

The settle result says so explicitly rather than leaving callers to infer it
from a null tab id, which `notLaunched` also produces.

* test(session-tabs): pin the id a first structured publication is minted under

The launch draft seed is keyed on `structuredAgentSessionTabId(sessionId)`
before the tab exists, while the mirror mints ids with collision avoidance that
can append a `:history-N` suffix. The two agree today only because a fresh
session's base id is unique. Pin that where the id is actually minted, with the
collision arm alongside it so the divergence the seed depends on staying away is
visible rather than assumed.

* test(native-chat): pin the route connection fallback on the un-mocked resolver

The suite that covers the builder stages `getConnectionIdFromState`, so it can
characterize the fallback but cannot catch a defect that lives in owner
resolution itself. This one runs the real resolution over real store rows: two
repos publishing the same worktree id on different hosts, which is the
documented case where the owner cannot be named and `undefined` is returned.
Red with both fix files at the previous head, green with them.

Reverts the two caller pins added to the route census — the feasibility
predicate is exported from the planner, which the census already permits, so it
passes unedited and needs no permit clause.

* fix(native-chat): keep the structured launch's own agent eligibility check

Quick create's structured launch narrowed its guard to a bare `agent` presence
check, so a creation carrying an agent that cannot hold a structured session
reported itself cancelled once dismissed, where it previously reported that it
had done nothing. Unreachable through both callers today, but it is the last
local eligibility check in a module that otherwise trusts its callers for the
route, so it is restored rather than left to the required-route typing — which
says nothing about the agent.

Also corrects two comments that called the quick-create request "persisted".
It lives in renderer session memory and dies with the renderer; calling it
persisted made the plan/adopt split read as restart recovery, when what it
actually buys is a route decided before the worktree exists.

* fix(native-chat): keep the structured feasibility query typecheck-clean

The query threaded its narrow settings through the store, but the route
store's settings must satisfy the full GlobalSettings that two of its
resolvers require, so the narrow copy never fit. Ride the named settings
on the built input instead: the caller still names them, so a React memo
still depends on them, and no store-shaped object is needed.

Also give the launch state its delivery mode unconditionally; the key is
required, and a conditional spread makes it optional under
exactOptionalPropertyTypes.

* docs(native-chat): name the feasibility query's one remaining settings asymmetry

The builder reads launch customization off the store while the routing gate
reads the named settings, so one answer has two settings sources. It cannot
diverge with the single caller passing the object the store already holds, but a
PR about removing split sources should not leave that unstated.

* fix(native-chat): keep a coalesced joiner's draft unsent

joinLaunchDelivery stripped the joiner's delivery mode when the launch it
joined had established none, and an absent mode reads as submit. A joiner
that asked for a draft therefore had its text sent — the send-without-
consent this PR exists to prevent. Fall back to the joiner's own mode only
when nothing was established, so the first caller still wins otherwise.

* chore: re-trigger CI

GitHub created no workflow run for e935ea5e42 — the pull_request
synchronize event was dropped. No content change.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 14:54:09 -07:00
Brennan BensonandMerge Sim 2626e2eca4 Make the structured turn lifecycle row durable so completed durations survive (#19695)
* Make the structured turn lifecycle row durable so completed durations survive

A structured-chat turn used to end by tombstoning its running lifecycle item,
which threw away the only durable record of when the turn ended. Completed
"Worked for" labels therefore depended on the renderer having observed the
turn finish, and vanished on reopen.

The lifecycle item is now revised in place, never tombstoned:
- running, with startedAt, at the provider's turn start
- completed or interrupted, with completedAt, at the provider's terminal frame,
  a user stop, or a child exit the host observed
- unverifiable, with no end, when a cold acquire finds a running row from a
  generation whose exit nobody observed

Both timestamps are the execution host's clock at receipt, captured before the
deferred sink, so the completed value is identical on every client and needs
no client clock. Codex history restore uses the provider's own second-granular
endpoints for turns that predate this change. Desktop and mobile read settled
durations off the journal through one shared selector, and anchor the live
counter on the host start with the client's local receipt so a skewed client
clock never leaks into the label. Locally observed durations remain the
fallback for hosts that still tombstone.

Timestamps live inside the existing turnLifecycle field, which old clients
strip, and every working-state consumer keys on state === 'running', so no
capability negotiation is needed.

* native-chat: avoid stale working status on settled turns

* test: align settled turn status expectations

* Name settled lifecycle rows by their terminal state

An interrupted or unverifiable turn must not read as completed for any
consumer that renders status text raw. One shared helper builds the text for
both providers from the lifecycle state.

* test: deduplicate turn lifecycle suites

Each behavior keeps one test; duplicated harnesses and restated cases go.

* Key lifecycle rows to their user item and record the provider's measured duration

A lifecycle row now names the user item that opened the turn by its provider
key, so clients attribute timing explicitly and fall back to journal order
only for rows from older hosts. A provider-initiated turn with no prompt can
no longer claim the previous prompt's duration.

When the provider measures the turn itself (Codex turn.durationMs, Claude
result.duration_ms) the terminal row records it and clients prefer it over the
host interval, so a turn shows the same number live and after a history
restore. Host receipt times remain the live-counter anchor and the fallback.

* Record a turn as a first-class journal item

The turn record is now its own item kind rather than a status row carrying a
lifecycle field: no text to misuse, and the fold matches the durable turn
record other systems keep. Rows that carry it are stamped journal schema v3;
every other row stays v2, so an older host keeps reading them and latches
read-only at the first v3 row instead of truncating the epoch.

Clients that predate the item would paint an unknown kind as a text bubble,
so the host publishes the legacy status form to any client that does not
advertise agent-session.turn-item.v1, through the same per-client seam
background tasks use. The downgrade is transitional and goes once no
supported release lacks the capability. The shared projection now renders
unknown item kinds as nothing, so later kinds need no gate. One shared reader
handles both forms for old journals and old hosts.

* Preserve observed turn end across settlement retries

* Retain turn attribution for loaded chat history

* Preserve Codex exit receipt across close retries

* Register completed turn duration reliability gate

* Keep earlier turns through a Codex rewind and count a mid-turn attach from the real start

Findings from an independent adversarial review of the typed turn record:

- A Codex rewind adopted the provider's item list as the new epoch, and the
  provider never returns the host's own turn rows, so every duration before
  the rewind point vanished. The host's turn rows are now spliced back beside
  the item each followed, and recovery no longer expects the provider to
  prove rows it never owned.
- The epoch row was stamped with the current schema version, so an older host
  latched read-only at row 1 of every new session, defeating the mixed
  version design. It carries no body and stays at v2; a stored-row test now
  reads SQLite directly, because the reader upcasts every row on read.
- A send Codex folds into a running turn shares the opening prompt's provider
  key, and the alias map credited the duration to the later prompt. The
  earliest submission naming a key now wins.
- The live counter anchored on first sight, so a client attaching mid-turn
  counted from zero. Published frames now carry the host's clock, the reducer
  keeps the last sample with its local receipt time, and both clients anchor
  on how long the host says the turn has run.

* Correct turn duration gate assertion reference

* Respect authoritative unknown native chat duration

* Preserve unverifiable timing across older host upgrade

* Record final completed turn duration reliability evidence

* Fix the CI failures the merge left behind

- A merged import list named the same module twice, which the native code
  quality plugin fails on.
- A running turn is now reported by the host with no duration, so the settled
  map carries an explicit null for it; the hook test still expected the entry
  to be absent.
- main gave the older-page action a cursor with a head-trim guard, so the
  retention test's epoch-only action no longer typechecks; it now passes an
  unbounded sequence, which is what the old shape meant.
- The roster comparator moved into the extracted module, leaving its import
  unused in the reducer.

* Split two files back under the line cap after the merge

Merging main put both one effective line over 300, and the cap forbids a
disable or a shave. The wire module's refusal vocabulary moves to its own file
and is re-exported, so its consumers are untouched; the host's four thin
mutation delegates move next to the functions they call.

* Advertise the turn-item capability on every client transport

Local IPC and mobile advertised it; the remote and web transports did not, so a
desktop paired to a remote host, the CLI, and web silently ran on the legacy
carrier forever and the canonical row was never exercised there. The renderer
that paints it is the same build on every transport.

* Update the web auth-frame expectation for the new capability

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 14:32:50 -07:00
Brennan BensonandMerge Sim 4408fe897a feat(sidebar): show native-chat subagents as sidebar child rows, like CLI agents already do (#19807)
* feat(sidebar): indent native-chat subagents under their session row

Stacked on #19311, which adds the background-task channel this reads. The
bridge maps agent-kind background tasks into AgentStatusEntry.subagents, and
the renderer status feed confirms per connection so a reconnect cannot leave a
child asserting live from a stream that ended.

* fix(sidebar): avoid completed age for unverifiable subagents

* fix(sidebar): preserve unverifiable child verdicts

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 12:29:30 -07:00
f2af92b2fa feat(native-chat): show live background work and name each row by kind (#19705)
* fix(codex): reserve the label's share of a qualified command row

A child's label is raw provider text and was spliced into the command
row unbounded, then the pair clipped to the description cap. A label at
or past that cap clipped the command away entirely, leaving a row of
kind 'command' that named an agent and showed no command - the failure
qualification exists to remove, inverted. The same clip could also cut a
surrogate pair, which boundSubagentField already guards against on the
agent row two lines away.

Give the label a reserved share and clip it the way the agent row does.

* feat(native-chat): show live background work and name each row by kind

The strip suppressed itself in three places: the Claude tracker blanked
its roster for the whole of any turn, the Codex tracker returned nothing
while a primary turn was open, and the renderer view gated on
`turnId === null`. Between them, work in flight was never shown — and a
task backgrounded in an earlier turn vanished from the strip as soon as
the next prompt was sent. Claude additionally dropped every foreground
subagent, so a fan-out reported nothing at all.

Report work while it is live, in all three layers. Foreground Claude
work is turn-scoped, so `result` retires it — that is the provider's own
outcome for a task it marked foreground, not a roster sweep. Nothing
settles a Codex child on turn end: those keep reporting well past their
parent, so turn frames only prompt a republish.

Name each ROW by kind — Subagent, Shell command, Workflow, Monitor —
instead of a generic "Background <kind>", each drawing the glyph the
shared tool-icon table already uses for that category. A row that
carries a provider description still shows it unchanged. The collapsed
header summary is deliberately untouched; it is owned elsewhere.

The conversation-command gate is unchanged in effect: an open turn
already refuses first, and Claude foreground work never reaches the
backgrounded set the gate reads.

* fix(native-chat): withhold the row stop Claude foreground work cannot honour

The strip now publishes foreground rows, but `stoppableTaskIds` still filters
on `backgrounded`, so `stopClaudeBackgroundTasks` resolved an empty target list
and returned `{ cancelled: false }` that no renderer reads: the user clicked
"Stop Subagent" and nothing ever happened.

Carry stoppability per row instead of widening the stop to a target the SDK has
no way to reach. `AgentSessionBackgroundTask.stoppable` is absent-means-yes, so
hosts that predate it keep their working control, Claude emits `false` only on
foreground rows, and the strip hides that row's button the same way it already
hides the stop-all a provider cannot honour.

* fix(claude): scope aggregate-roster authority to the work it enumerates

`background_tasks_changed` lists BACKGROUNDED tasks, so a foreground subagent
can never appear in it. Treating it as the whole world meant any such frame
cleared every live foreground row mid-flight and then dropped every later
foreground `task_started` for the rest of the session, killing the in-turn
fan-out the strip exists to show in any session that ever backgrounds anything.

Decide `backgrounded` before the staleness guard and apply the guard only to a
backgrounded start, and retain live foreground entries across a roster replace.
Retained rows count against MAX_TRACKED_TASKS, so the map stays bounded, and a
stale backgrounded start the roster no longer lists is still dropped.

* test(native-chat): pin the strip's monitor amber to the constant that defines it

`MONITOR_GLYPH_COLOR`'s comment claimed a test held it and AgentStateDot's amber
together, but no test imported it — the assertions hardcoded 'text-yellow-500',
so the two could drift with every test still green. Read the colour from the
module, which is what the comment always said was happening. Drop the unused
`BackgroundTaskGlyph` export too: nothing outside the module names it.

* fix(native-chat): keep the task list open across a gap in live work

The strip is now mounted on live work, so a sequential fan-out unmounts it
between one subagent finishing and the next starting: local `useState` meant
the expanded list collapsed itself on every such gap, on top of the strip
flickering above the composer.

Hand the disclosure to the session, keyed by session id so it does not leak
across a session switch. The strip is now controlled and holds no state of its
own, which is what makes it survive its own mount churn.

* fix(codex): route every command-row cut through one surrogate-safe clip

`boundLabel` avoided splitting a pair, then `qualifiedDescription` re-cut the
COMPOSED string with a raw slice: label (<=96) plus separator plus description
(<=512) is up to 611 chars, so that second cut landed at an arbitrary index
inside the description and could publish a lone high surrogate — lossy through
any non-JSON UTF-8 hop. `parse` had the identical hazard on an unqualified
primary-thread command.

One `boundText` helper now owns all three cuts, so no path in the file can emit
a lone surrogate from well-formed input.

* fix(claude): keep terminal evidence for ids an aggregate roster never lists

Narrowing the admission guard to backgrounded starts left a finished FOREGROUND
id with no defence: `replaceAggregateRoster` wiped `terminalTaskIds` wholesale,
so after any `background_tasks_changed` a replayed `task_started` revived a task
whose completion had already been seen — and only a later `result` could settle
it again.

Scope the wipe the same way the guard was scoped: delete only the ids the
incoming roster actually enumerates. A roster still overrules terminal evidence
for the work it lists, which is what that behaviour was added for.

* fix(claude): keep retained rows in place and evict the stalest, not the newest

Re-adding retained foreground entries after the roster made a live row the user
is reading jump below the backgrounded rows on every `background_tasks_changed`,
and the cap `break` kept the STALEST retained rows while dropping the newest.

Merge in the tracked map's own order so a surviving row holds its position, and
count the overflow up front so eviction takes the oldest retained rows. Roster
entries are never starved and the map stays bounded either way.

* fix(claude): retire leftover foreground rows when the next turn starts

A foreground `task_started` arriving with no turn open has no `result` coming
to retire it, so it sat in the strip indefinitely — with no per-row stop, since
foreground rows are not stoppable — and refused conversation commands behind an
instruction nobody could follow.

Settle on turn start as well as on `result`. This is cleanup only: visibility
never consults `startsTurn`, so a missed one degrades to today's behaviour and
can never switch the feature off. It shortens the row's life to the next turn;
the case where no further turn is ever sent is filed separately.

* fix(agent-session): withhold unstoppable rows from readers that predate them

Rule 3 of remote-wire-compatibility: changing what the host publishes reaches
old clients with no wire change. The Claude host published no foreground rows
before this feature; it does now, and a client that cannot read `stoppable`
draws a per-row Stop on every one of them — Claude always sets
`supportsTaskStop` — which filters to the backgrounded ids, stops nothing, and
returns a result no renderer inspects. That is the dead button `stoppable` was
added to remove, reappearing across a version skew.

Negotiate it. A client can advertise the existing background-task-stop
capability and still predate `stoppable`, so this needs its own constant.
Readers that do not advertise it get unstoppable rows dropped, and a state whose
every row is dropped becomes no strip — exactly their pre-feature view.

RUNTIME_PROTOCOL_VERSION is not bumped: this adds an optional field and a new
negotiated capability, and changes no existing field's meaning, which is the
explicit do-not-bump case in protocol-version.ts.

* test(agent-session): name the projected rows so the fixture typechecks

An indexed lookup into the fixture's task list is possibly-undefined under
`pnpm tc`; the rows are more readable named anyway.

* test(web): advertise the row-stop capability in the e2ee auth expectation

The web e2ee handshake started sending
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, and this test asserts the
advertised list by deep equality, so it went red on CI while every targeted
test run stayed green. Add the capability in the position the router sends it.

* test(claude): pin why the roster empties mid-turn in a sequential fan-out

The strip unmounting between two sequential subagents is truthful, not a swept
row: A leaves on the provider's own terminal frame, B does not exist yet, and
backgrounded work spanning the same gap holds the roster open — so an empty
roster is never work the strip is hiding.

Also pins the previous-turn rule against the one the subagent roster already
applies on the same frame: a still-working FOREGROUND child becomes
`unverifiable` there and a backgrounded one is left alone, so the strip drops
the first and keeps the second rather than asserting `live` for either.

---------

Co-authored-by: Merge Sim <merge-sim@users.noreply.github.com>
Co-authored-by: Merge Sim <sim@local>
2026-09-10 12:16:30 -07:00
Brennan BensonandMerge Sim ebb1acfa37 refactor(agent-status): publish structured sessions into the hook server store (#19683)
* refactor(agent-status): publish structured sessions into the hook server store

Structured (native chat) sessions have no PTY and no hook script, so their
status never reached the hook server's store; #19217 gave `worktree ps` its
own adapter over the structured feed instead. The feed now writes every
projection into that store through a status sink the runtime wires, drops
the row when the host closes the session, and `worktree ps` reads the one
snapshot like every other agent.

Rows carry a `structuredHost` marker and the journal clock; they are never
persisted to last-status.json, and the main process does not forward them
to the renderer yet, whose feed bridge still owns them until it is retired.

Design and the two follow-ups: docs/reference/agent-status-store.md.

* chore: drop stray @pnpm/exe lockfile entry

An unrelated local pnpm run added @pnpm/exe as a packageManagerDependency
with no package.json change, so CI's --frozen-lockfile install failed
before any job ran.

* docs(agent-status): describe the step that actually landed

The design record claimed PR 1 deletes RuntimeAgentRowStore, drops the
retained-versus-hook reconciliation, stamps terminalHandle on OSC rows, and
tags rows with a source field of 'structured-host'. None of that is true of
the shipped code: the retained store and its reconciliation are still in
place, and the row field is structuredHost: 'held' | 'owned'.

AGENTS.md points every future contributor here before they touch agent
status, so split the roadmap into the 1a that landed and the 1b that has not,
and name the fields the code actually writes.

* fix(agent-status): pair session removal with the status-row forget

A session dropped from the host's map without an explicit forget left its row
in the store forever: `structuredHostOwned` bypasses the staleness check, so a
failed re-attach (the Claude rewind path reaches one) stranded a permanently
working agent in `worktree ps` and on mobile with no UI able to clear it.
Deletion and forget are now one operation both callers route through.

* fix(agent-status): give orcad the store worktree ps reads from

`orcad` constructed its runtime with neither `getAgentStatusSnapshot` nor
`structuredAgentStatusSink`, so once `worktree ps` sourced rows only from that
snapshot the headless host published nowhere and listed nothing. The hook
server's store is a module singleton whose import tree never reaches Electron,
and its file paths come from `start()`, which orcad never calls.

* fix(agent-status): drop a structured row without a renderer clear

`dropStructuredStatus` went through `clearPaneState`, which fans a pane clear
out to the renderer for a pane key the renderer's own feed bridge still writes
- so 'exactly one writer per pane key' held for writes and not for deletes.
`dropStatusEntry` routes through the status-drop tap instead, and skips the
resume-identity remnant: a structured session has no pane to resume into, and
every null-status publish would otherwise re-mint one.

* test(agent-status): pin both half-migration structured-row filters

Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had
a single assertion, so deleting either — the first step of PR 2 — was green
everywhere. Also covers the perf skip and the drop's lack of a renderer clear.

* docs(agent-status): correct three statements this PR made false

The sink JSDoc claimed only tests construct a host without one; `orcad` did.
The doc argued a structured row needs no tab mirror 'because headless serve has
no renderer', reasoning about exactly the topology the wiring had not reached.
The deleted runtime adapter's warning that the pane key must be the DERIVED one
- never a bearer handle or minted worker key - was lost with it.

* test(agent-status): declare orcad in the hook-row producer census

Wiring the hook store into the orcad runtime added a production site that
hands hook rows to a consumer, which the census ratchet pins deliberately.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 11:27:27 -07:00
Neil f2d5711b2d fix(native-chat): keep an older page from punching a hole in the transcript (#19845) 2026-09-10 03:10:04 -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
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 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
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 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
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
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 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
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
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
063c1caa6f perf: reuse AI-vault path matchers and query and sort keys (#19469)
* perf: reuse AI-vault path matchers and query and sort keys

* test(ai-vault): add cross-platform path and sort-key differentials

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:43:36 -07:00
4a502d2e19 perf: index terminal neighbor order and remaining membership (#19453)
* perf: index terminal neighbor order and remaining membership

* test(session): prove indexed terminal close picks the pre-index neighbor

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:43:10 -07:00
f242c99af4 perf: scope activation inventory to the owning host and workspace (#19447)
* 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>
2026-09-08 19:42:25 -07:00
44eb95fc6b perf: bound tool pairing and avoid unused attribution arrays (#19468)
* perf: bound tool pairing and avoid unused attribution arrays

* test(native-chat): cover mobile pair-limit shapes and group allocation tests

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:42:18 -07:00
Neil c3a415487b perf(terminal): avoid quadratic status-marker scans in output bursts (#19373)
* 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.
2026-09-08 19:41:42 -07:00
d872a5a8b8 perf: avoid repeated pending SOCKS payload copies (#19439)
* perf: avoid repeated pending SOCKS payload copies

* test(browser): cover pipelined SOCKS request-tail ordering in pending buffer

Also document takeBuffer's view-not-copy contract and restore node: import order.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:41:37 -07:00
6a9c5d8ce3 perf: replay pending chat asks with a FIFO cursor (#19496)
* perf: replay pending chat asks with a FIFO cursor

* perf(native-chat): track the pending-ask FIFO with counters instead of a queue

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:40:30 -07:00
96216601bc perf: retire exhausted buckets from host-balanced listings (#19493)
* perf: retire exhausted buckets from host-balanced listings

* test(listing): guard the bucket-retirement invariant and starvation fix

Document why in-place compaction keeps bucket[round] defined and preserves
first-appearance host order, and cover cap-exact filling plus small-host
representation so a drifting retirement predicate cannot emit blank rows.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:40:25 -07:00
2c5cd845a2 perf: append to owned project source lists without repeated copying (#19465)
* perf: append to owned project source lists without repeated copying

* docs(projects): record draft-ownership rule for in-place project merge

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:40:10 -07:00
df8a43724f perf: index document history workspace and path identities (#19461)
* perf: index document history workspace and path identities

* docs(browser): note doc-history dedupe must mirror location equality

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 19:39:42 -07:00
86fe80da25 perf: preserve store state on unchanged document titles (#19459)
* 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>
2026-09-08 19:39:27 -07:00
Brennan BensonandMerge Sim e80fae0c4d Summarize turn file changes and preserve resolved prompt receipts (#19229)
* feat(chat): summarize turn changes and retain resolution receipts

* fix(chat): defer turn diff details and localize resolution times

* fix: complete approval projection test fixture

* Align turn diff disclosure chevron and indent details

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 17:50:51 -07:00
Neil bba68b1bdd fix(pi): finish the dialog-wait signal on every surface (#19533)
* 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.
2026-09-08 03:06:54 -07:00
Jinwoo Hong 12f53da542 Remove settled-worker automatic resume and hibernation fences (#19544)
* 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
2026-09-08 05:14:59 -04:00
Jinwoo Hong 98fdbc4ade fix(orchestration): file federated worker mail under the coordinator Run (#19542) 2026-09-08 04:03:07 -04:00
53852c9ca4 feat(terminal): make the contrast floor user-configurable (#10754) (#18126)
* 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>
2026-09-08 00:39:33 -07:00
6c85e33197 perf: lazily index case-insensitive Windows environment keys (#19483)
* 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>
2026-09-07 23:27:37 -07:00
Neil ed881889c4 fix(terminal): fold-safe CAN/SUB and double-ESC handling in partial-escape tail (#19521)
`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.
2026-09-07 23:25:46 -07:00
5b111ae607 perf: memoize ancestry when selecting foreground agents (#19502)
* perf: memoize ancestry when selecting foreground agents

* perf(foreground): scope the ancestry memo to its ancestor and process snapshot

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-07 23:24:55 -07:00
Neil ef6ad22431 perf(native-chat): preserve historical tool rows while streaming (#19364)
* 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.
2026-09-07 23:24:43 -07:00
Jinjing 36d209f515 Verify failure causality in PR checks fix prompt before making changes (#19435)
* 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.
2026-09-07 22:47:26 -07:00
OrcaWinandm4air b52d777c95 perf: index prior memberships during project identity succession (#19463)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-07 22:38:01 -07:00
OrcaWinandm4air 894fbc8698 perf: normalize only retained browser history candidates (#19460)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-07 22:37:55 -07:00