Commit Graph
9156 Commits
Author SHA1 Message Date
Brennan Benson 40dabe893f test(orchestration): name why the readiness settlement fakes are cast 2026-09-25 04:50:58 -07:00
Brennan Benson 283819d17a fix(native-chat): a message rejected while its chat was closed reads as not sent
A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.
2026-09-25 04:45:59 -07:00
Brennan Benson 46b6472ab8 fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down
The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.
2026-09-25 04:41:15 -07:00
Brennan Benson 34d0bb0d34 fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer
The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.
2026-09-25 04:37:04 -07:00
Brennan Benson c7a652fe29 fix(native-chat): an attach that fails after indexing its child leaves no child behind
A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.
2026-09-25 04:27:53 -07:00
Brennan Benson 29a340b0b4 perf(native-chat): the recovering open reads the journal once
Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.
2026-09-25 04:17:30 -07:00
Brennan Benson f3e52d206b fix(native-chat): an adoption that fails to import keeps the conversation open
The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.
2026-09-25 04:17:28 -07:00
Brennan Benson 82f9bfd7b3 fix(native-chat): settle queued messages only for the child that ended
A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.
2026-09-25 04:17:17 -07:00
Brennan Benson c753e8ab9a fix(native-chat): a message is accepted, then delivered
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
2026-09-25 03:31:08 -07:00
Brennan Benson 12dde3710b Merge #22812 into the PR 1 base
# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
2026-09-25 03:15:53 -07:00
Brennan Benson 8fd4558d48 Merge #22811 into the PR 1 base 2026-09-25 03:15:33 -07:00
Brennan Benson 5435973e4d docs(native-chat): drop the send rebase from comments that still described it 2026-09-25 03:03:34 -07:00
Brennan Benson fa9b76a48a docs(native-chat): say mutation admission checks only the writer lease 2026-09-25 02:53:11 -07:00
Brennan Benson bd72b13662 test(native-chat): state why the teardown test's fake journal is safe to cast 2026-09-25 02:48:51 -07:00
Brennan Benson 50d2e33099 perf(native-chat): a publish behind a delivered commit reads nothing
Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.
2026-09-25 02:43:30 -07:00
Brennan Benson f79eb9b88f test(worktree-activation): restore the OMP surfaced-agent resume test
The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.
2026-09-25 02:33:29 -07:00
Brennan Benson 2bd44f5f7d test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite
The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.
2026-09-25 02:22:25 -07:00
Brennan Benson 427dc8de83 test(native-chat): each row reaches an open chat once, and a live handle enters only through the map 2026-09-25 02:17:50 -07:00
Brennan Benson 97bf3bb526 test(native-chat): an epoch replacement reaches the open chat 2026-09-25 02:17:49 -07:00
Brennan Benson 6dad39a1dd fix(native-chat): every journal append reaches the chats that are open
A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.
2026-09-25 02:17:49 -07:00
Brennan Benson 44d797728c fix(native-chat): name a chat write by its target, not the owner generation
A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.
2026-09-25 02:07:14 -07:00
Brennan Benson 713b126feb test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else
Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.
2026-09-25 02:01:14 -07:00
Brennan Benson 54ce5fca0d refactor(native-chat): stop threading the owner kind through a reservation
A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.
2026-09-25 01:58:00 -07:00
Brennan Benson 35742fd3c0 refactor(native-chat): normalize terminal-handoff lease values once at decode
Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.
2026-09-25 01:57:10 -07:00
Brennan Benson b58818524d refactor(native-chat): type the owner-status reply as the host sends it
The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.
2026-09-25 01:11:58 -07:00
Brennan Benson 250250e59d refactor(codex): name the pinned rollout lookup for what it does
With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.
2026-09-25 01:02:14 -07:00
Brennan BensonandClaude faececed48 test(native-chat): type the stand-in roster decoder without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:29:21 -07:00
Brennan BensonandClaude d0f4dae350 fix(native-chat): stop calling a starting chat "mid-handoff"
A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:28:09 -07:00
Brennan BensonandClaude 048391506f refactor(native-chat): drop the transcript helpers only the handoff called
appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:27:15 -07:00
Brennan BensonandClaude 42d10eee15 refactor(terminal): remove the agent-session PTY write gate
The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:22:41 -07:00
Brennan BensonandClaude 12efdefd1f fix(native-chat): keep the owner-status read behind an in-flight attach
The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:02:53 -07:00
Brennan BensonandClaude b50c57472e test(native-chat): pin that a start dead before proving owes no settlement
The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-25 00:00:25 -07:00
Brennan BensonandClaude e048579cf5 test(native-chat): type the stalled snapshot drain without a cast
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-24 23:57:28 -07:00
Brennan BensonandClaude 3cbf6b58f4 docs(native-chat): stop citing the removed handoff in lifecycle comments
Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-24 23:56:29 -07:00
Brennan BensonandClaude abafbbcc08 refactor(native-chat): drop helpers only the terminal handoff called
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-24 23:54:29 -07:00
Brennan BensonandClaude eab4ef1edf fix(native-chat): never let the pre-stop snapshot hold a chat's stop
Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-24 23:54:27 -07:00
Brennan Benson c867bd2c30 refactor(native-chat): remove the unused terminal handoff
No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.
2026-09-24 23:36:44 -07:00
Brennan BensonandClaude fec8fe4822 fix(native-chat): offer a resume for every chat that was working, and say what it was doing (#22560)
* fix(native-chat): offer a resume for every chat that was working, and say what it was doing

* fix(native-chat): keep a child-work resume offer after the chat is reopened

The offer's cut-off work was read from the items' current state, so once the
chat was opened (reattaching the provider, which rewrites the rows it lost),
a chat offered for its stopped subagents or background tasks dropped out of
the dialog and its retry. Judge the revision rows written after the marker's
cursor instead: they only accumulate, so the reading is the same before and
after reattach, and the per-run pre-reattach copy is no longer needed.

Also pass the lease fence into the shared "shows work" check, as the status
feed does, so a send stranded under an older fence cannot hold an unheld
session's provider alive while the sidebar shows it idle.

* refactor(native-chat): offer is the working check taken right before each child stops

Every review loop found the same bug class in the "is this offer still owed?" re-derivation that
ran when a child was stopped and again after restart. It re-read a journal the provider had already
rewritten on reattach (a notice turn of its own, restated subagent rows) and kept misreading it:
a send made after an earlier completed turn was dropped at shutdown, and Claude's notice turn
refused the resume after restart.

- Teardown now snapshots each session with the sidebar's working check in a new eviction step
  right before its provider child is stopped (after draining events Orca already accepted), and
  keeps it once the stop is proven. The capture-first/confirm-on-stop re-judgment is gone.
- After restart an offer is withdrawn only by a newer user message, dismissal or expiry, beside
  the structural checks (record, support, lease, no fork). Listing, retryable and the pre-send
  check no longer re-derive work state.
- The pre-send barrier drains again while a provider keeps streaming instead of refusing; both
  providers queue a message sent mid-turn.
- Row activity stays a display-only read of the rows after the marker's journal cursor.

* test(native-chat): pin the re-drained admission barrier and the proven-stop gate on offers

* test(native-chat): cover a send-shaped offer across a closed provider notice turn

* refactor(native-chat): drop the pre-stop drain nothing depended on

* docs(native-chat): align marker and retry comments with the simplified offer rule

* fix(native-chat): an unfinished admission drain no longer refuses the restart continuation

The drain before the continuation's pre-send check refused the send when provider events were
still arriving at its 2 s bound or the barrier failed. That refusal journaled the continuation as
rejected, so its own message then read as the user moving on and the offer could never be
retried. The drain is now best effort and the pre-send check judges what the journal holds.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin a stalled or failed admission barrier dispatching once

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): name a cut-off reply from the settlement's row, not the turn as restated

A reattached provider may restate the offer's turn, so the row's mid-reply label read off the
turn's current state could vanish and count the cut-off reply's own tool calls as background
commands. Also corrects the ineligible-offer comment to match the user-moved-on rule.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): the offer is a stop-time snapshot on the marker

The marker records the main agent's own state, the pending prompts and the
live child roster at the moment before its child stops; the dialog row,
status bar and candidate read only that snapshot. Deletes the journal
cut-off reader, journalCursor, the offer TTL and the pre-send admission
drain. Superseded offers and chat closes now delete their records.

* test(native-chat): every offer ending deletes the durable record

* fix(native-chat): preserve restart offers on unreadable journals

* fix(native-chat): release idle sessions with pending sends

* fix(native-chat): keep a message held while the CLI starts from being released

Idle release had been switched to the sidebar's working check minus pending sends, which dropped
the rule that a send held while the provider CLI is still starting keeps the session. An unheld
chat left during startup was then evicted and its message refused. Restore the release rule this
branch never needed to change: an open turn, or a pending send while the child is starting.
Also keeps the host file within its line budget.

* fix(native-chat): delete a restart offer whose conversation forked

A forked conversation can never become the marked one again, but its offer was only skipped, so
with no expiry it sat unseen in the recovery file forever. Report it for deletion on the same path
as a newer user message. Also corrects comments that still called listing read-only.

* fix(i18n): keep Agent untranslated in the Japanese subagent activity rows

The catalog keeps Agent in English for Japanese, and the localization
gate rejects the translated form.

* fix(native-chat): don't call a mid-reply command monitoring in the resume dialog

The live task roster also lists the foreground command a reply is
running, so a chat stopped mid-command read "Was mid-reply · Monitoring:
<command>". Speak monitoring only for an idle lead, as the sidebar does;
the row's tooltip still names every task.

* chore(native-chat): state the mid-reply label rule exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 23:19:21 -07:00
Brennan Benson e0144a9eb6 fix(native-chat): show the Codex and Claude model picker the moment a chat opens (#22756)
* fix(native-chat): show the Codex and Claude model picker the moment a chat opens

A new structured chat showed no model picker until its session had been
created, spawned, initialized and had answered a model listing — and the
picker then listed the models a second time. Codex's listing often goes to
the network, so the picker took 0.6-2 s to appear.

- Keep a host-owned model catalog per agent and account home, persisted on
  success only and refreshed in the background once it ages out. A new
  read-only agentSession.modelCatalog RPC answers from it without a live
  session; sessions reuse it instead of listing again.
- Render the picker while the launch is still provisional, showing the saved
  default. A pick made before the session exists is held and applied once it
  publishes; only the host's acceptance saves it as the default.
- Mark the model and effort set in the user's Codex config as the listing's
  default (config/read), so the first frame names what the chat will run.
- Resolve the account a record-less read would use without running launch
  preparation, which writes and syncs account state.

* fix(codex): disable plugins in the model catalog probe app-server

* fix(native-chat): read the host model catalog only for panes on this machine

* fix(native-chat): name a pre-report model only for a chat this view launched

* test(native-chat): pin the launch latch across publish

* test(native-chat): pin a held pick reaching the host before the first send

* fix(claude): pin the catalog probe's config dir by the session spawn's rule

* fix(native-chat): read the host model catalog only for a visible chat

* fix(native-chat): rewrite the model catalog file only when a listing changes

* test(native-chat): type-check the first-send order fixture

* refactor(native-chat): keep the structured options hook under the line cap

* fix(native-chat): send the first turn only after every pick held during launch settles

* fix(claude): name no default effort from the catalog probe listing

* fix(native-chat): name no listed default model for a chat resumed from history

* refactor(native-chat): let the launch own picks made before it publishes

A pick made while a chat launches had no fence to go to, so the pane held it
and flushed it after publish; every other sender (the outbox, the launch
prompt) then needed its own gate to wait for that flush. The launch now keeps
those picks in its own state, applies them against the create receipt's fence
before it counts as published, and every sender follows publish by
construction. The pane flush, the outbox gate and the module-wide held-pick
registry are gone.

The launch also snapshots the saved selection its create seeds when the intent
is built, so a pick in another chat no longer relabels one still launching, and
a pick the host refuses is reported the way a refused mid-session pick is.

* fix(native-chat): name no default model a workspace's own config can replace

The catalog's default is the account's, read without a working directory, but
a chat runs in its worktree, where a project config (Codex's .codex/config.toml
between the project root and the worktree, or a Claude .claude settings file
that sets a model) picks the model instead. The picker named the account
default there while the chat ran the project's model.

A new chat's catalog read now names its worktree. The host checks that
workspace for such config (existence only for Codex, the model key for
Claude) and, when any is present or the workspace is not a local directory,
serves the listing with no default, so the picker names nothing until the chat
reports its model.

* fix(native-chat): name the listed default model before the report only for Codex

* fix(claude): let an option pick made while Claude starts wait for it instead of being refused

* fix(codex): name no listed default when the configured model is not in the listing

* fix(native-chat): show the picker as unavailable until a published chat attaches

* fix(codex): keep the catalog probe's listing when config/read stalls

* fix(native-chat): write the pending model catalog save before quit

* chore: drop an unrelated lockfile rewrite

* fix(native-chat): rename the catalog store's listing parameter off the global fetch name

* chore: drop an unrelated lockfile rewrite

* fix(native-chat): name the model Claude will run before its first turn

* fix(native-chat): keep Claude's pre-turn applied effort out of the saved session options
2026-09-24 23:04:07 -07:00
Brennan Benson 8f7cbad07b feat(mobile): name the machine after pairing (#22104)
* feat(mobile): confirm host identity after pairing

* refactor(mobile): unify host descriptor state

* chore(i18n): translate the last-known host descriptor label

The remote-host row's "Last known" label shipped in English only. Every other locale now carries it, worded as each catalog already words "last known".

* fix(mobile): make the pairing naming step safe to abandon and show the machine live

Pairing:
- An unreadable status.get reply no longer strands the pairing race: the
  descriptor read ran inside the race's success handler and threw, so the
  candidate was never counted and a direct-only pairing sat on
  "Connecting..." until the timeout. The race now reads the status through
  a reader that returns null instead of throwing.
- A pending pairing is now a small state machine: a save in flight owns the
  outcome (Cancel, back, or unmount no longer clear the journal under it),
  a failed save stays pending and the naming screen shows the error with
  Save still available, and Cancel never rejects.
- When the desktop refuses relay provisioning, the journal is cleared before
  the naming step instead of at save, so an app kill on that screen no longer
  blocks every later scan with "recovery pending".
- A pairing that resolves after the screen went away is cancelled rather
  than left with its journal.
- The screens keep their root ref callbacks stable (the latest pending
  pairing is read from a ref) instead of re-creating them per pairing.
- The label field's placeholder shows the name that an empty label saves.
- "Is this an existing host" is derived from the id identity resolution
  hands back, so host-store and its tests go back to main's shape.

Machine descriptor:
- Mobile keeps the host-reported machine name and OS in memory only, filled
  by the status reads that already happen, and labels it "Last known" from the
  row's own connection state. This drops the per-host AsyncStorage copy, its
  web sibling and web-overrides entry, and the removal cleanup. It also fixes
  a latch: freshness used to stay true for the whole process once a host
  had answered.
- Desktop reads the descriptor through lastVerifiedRuntimeStatus and marks
  it "Last known" using the same reachability verdict as the row's dot.
- Drops the unused hostname/previousPlatform resolver inputs and moves the
  "OS · machine" formatting into the shared resolver.

Also restores main's page-only Reconnect gate in the host header (#22326),
undoes the no-op toStoredHostProfile reformat, and re-measures the web
session route at 4222 modules (one fewer: the dropped web persistence file).

* test(mobile): re-record RPC goldens for the deferred pairing save

Repins baseline to the pairing fix commit and re-records every golden.
Against main, 781 goldens move only in the header (baseline everywhere,
adapterSha256 for the pairing adapter family). Six pairing goldens move in
the body, and only in effect order: the pairing now resolves (closing its
candidate sockets) before the naming step saves the host, and a refused
relay provision clears its journal before the save rather than after. The
set of effects and every outcome match main.

This also removes the unhandled-rejection effects and the failed
result-absent/result-null cells that the previous recording captured from
the pairing race's throwing status read.

* fix(mobile): keep the machine name out of the host header title while the label loads

The host screen starts with an empty saved label and loads it asynchronously. With a descriptor
already in memory, the resolver fell back to the machine name as the title for that window, so
opening "Windows-Low Spec" briefly titled the header with the Mac's name. Read the descriptor
only once the label is known.

* test(mobile): build the pending-pairing status through its schema so the test typechecks

The mobile tests typecheck ratchet rejected a partial status literal: the reply type keeps every
optional field as a required key. Parsing the literal through the status schema yields that shape
without a type assertion.

* test(mobile-web): re-pin the session route closure after merging main

#22301 added two src/shared modules this route reaches; its own CI never ran this suite, so the
merged branch read 4224 against the 4222 pin. Measured on the merge.

* feat(mobile): name each host by what its desktop reports

Pairing saves the host immediately again and names it after the machine name the
desktop publishes; every connection refreshes that name and OS, so a rename on the
desktop reaches the phone. A name typed on the phone's Edit screen is kept as a
phone-local override that wins; clearing it returns to the desktop's name.

- Stored host profiles gain optional personalName, lastKnownMachineName and
  lastKnownHostPlatform; `name` stays the resolved value older builds read.
  Legacy records classify a generated "Host N" as desktop-named and any other
  name as a phone override.
- The connection layer runs one retrying status probe per connected host and
  records the descriptor; the capability probe becomes a projection of it.
- Rows and the header always show the OS, add the machine name under a phone
  override that hides it, and mark it "Last known" while the host is offline.
- Removes the deferred-save naming screen and the one-shot home status fetch.
- Name rules move to host-name-identity.ts and the host-list mutation queue to
  host-list-mutation-queue.ts; an unchanged mutation no longer rewrites storage.

* test(mobile): restore the RPC recordings to main's

Pairing saves the host back to back again and the recording adapter is main's, so
every recording matches main byte for byte; the earlier deferred-save re-record and
its baseline repin no longer apply.

* fix(mobile): keep stored host name identity across snapshot saves and on the web page

A connection re-saves its host profile snapshot on relay credential rotation or relay
re-resolution. The re-pair merge let that snapshot's name identity win, so a phone rename
cleared or changed since connect came back, and a newer desktop machine name rolled back.
The stored record now keeps the name identity on every save; the save supplies the rest.

The web page receives only the app's resolved host name, with no identity fields, so the
docked host header treated a phone rename as "no override" and titled the host with the
live machine name. The display hook now reads such a source the way storage reads a legacy
record: a non-generated name is the user's label.

* fix(mobile): hand the web page the host's stored name identity

The page received only the app's resolved host name, so it had to guess whether that
name was the phone's override or the desktop's name. It guessed "override" for any
non-generated name, which froze a desktop-adopted name as the title after the desktop
was renamed, and left an offline page header without the last-known OS and machine name.

The shell now puts the stored personalName and last-known descriptor on the init host as
optional fields. The page reads them exactly as the app does; a page handed its host by an
older shell still falls back to classifying the name, and an older page ignores the fields.

* fix(mobile): drop an unreadable host name field, not the whole host

The stored host record and the page's init host checked the platform against a closed list
and required non-empty names. A value this build does not know, such as a platform added in a
later build, failed the whole record: the host list dropped the paired host and the next write
persisted the list without it, and the page refused its init message. Those three optional
fields are now salvaged, so an unreadable value drops only that field.

Also states the one exception to the stored name rule (an OS reported without a machine name
keeps the adopted name), and brings four mobile test files in line with the branch: the edit
screen now saves `personalName`, and host opens now start a descriptor status probe.

* refactor(mobile): name the shared host name fields for their role

* fix(mobile): drop the "Last known" prefix from the host machine line

The OS and machine name line under a host's name reads the same whether or not the
host is connected; the connection status already says when it is offline, and a
prefix that users could read as applying to the name added nothing. Removes the
resolver's liveness input and the desktop row's translation key.
2026-09-24 22:35:22 -07:00
Jinwoo Hong 8fa5883217 fix(terminal): stop dropping visible alt-screen output tagged as hidden-resize repaint (#22587)
* fix(terminal): stop dropping visible alt-screen output tagged as hidden-resize repaint

After a resize that main received while a PTY was hidden, main tagged the
next chunk it accepted as background. The hidden delivery gate drops chunks
before they reach that tagging, so the tag outlived the hidden period and
landed on the first chunk after reveal. On the alternate screen the renderer
dropped that chunk without a model restore and "repaired" it with a
cols-1/cols resize pulse. OpenTUI debounces SIGWINCH and ignores an
unchanged size, so OpenCode's full post-reveal repaint was lost for good.

Remove the hidden-resize tag lifecycle in main and the renderer's
background alt-screen skip and pulse. Reveal already restores from the
host model and queues live chunks until the restore completes, so later
chunks are in order with the model and are written like any other chunk.

The snapshot-restore path that omits a too-wide alt frame no longer pulses
either: when the fit lands back on the capture grid (no real SIGWINCH), it
requests a fresh model restore instead of relying on the app to repaint.

* fix(terminal): restore a dropped reattach alt frame from the model when the fit lands on its capture grid

The reattach replay omits a too-wide alt frame and relied on the grid push's
resize plus SIGWINCH to make the app repaint it. When the fit lands back on the
capture grid that is a same-size signal, which OpenTUI ignores, so the alt
screen stayed blank. Record the omitted frame's capture width and request a
model restore after the grid push in that case. The reattach fit moves to
reattach-grid-fit.ts to keep apply-reattach-payload.ts within its line budget.

* test(terminal): drop type assertions from the reattach alt-frame test

* test(terminal): dispose the reattach alt-frame test's pane connection
2026-09-25 01:18:17 -04:00
Jinwoo Hong 2c7609bf6a fix(terminal): serialize only the visible width after a column shrink (#22586)
* fix(terminal): serialize only the visible width after a column shrink

xterm does not reflow the alternate buffer (or a normal buffer under
pre-21376 ConPTY), so after a shrink each line keeps its old length.
SerializeAddon walked every non-final row to line.length, so any snapshot
taken after a shrink carried stale right-hand cells that wrapped into extra
rows on replay; restores repainted that garbage and a differential TUI such
as OpenCode never cleared it.

Clamp the row walk and the wrap-boundary lookups to the terminal's columns
in Orca's addon-serialize source patch, and regenerate the bundles, maps and
lockfile hash per docs/reference/xterm-patch-regeneration.md.

* test(terminal): read shrink-snapshot fixtures through public APIs

Drops the private-terminal casts the casting gate flags; the normal-buffer case
now drives a plain pre-21376 ConPTY terminal and its SerializeAddon directly.

* fix(terminal): blank a wide glyph clipped by a column shrink when serializing

After a non-reflowing shrink a width-2 glyph can have its lead half in the last
column and its trailing half past the grid. Serializing the lead half makes the
replay wrap it to the next row and shift every row below, so serialize that
cell as a blank and keep the row exactly the grid's width. A glyph ending
exactly at the edge is unchanged.

* test(mobile): move the session closure pin past the main agent status modules

#22452 added src/shared/main-agent-status.ts and src/shared/agent-turn-outcome.ts,
which agent-status-types.ts imports, so the session route's closure grew by two
local modules (4218 -> 4220). That change was src/shared-only, so its own CI never
ran this suite; main has been at 4220 since, and any PR that fires the mobile web
app job fails on the stale pin. Measured on 4064653740 and on origin/main 3ea15dd0a2.

* test(terminal): differential serialize round-trip fuzz and transcript replay

Seeded VT streams (text, CJK/emoji/combining, SGR, cursor/edit ops, scroll
regions, DECAWM/IRM, alt-screen variants, DECSC, and shrink-heavy resizes)
drive a source terminal in three modes: reflowing normal buffer, alternate
buffer, and a non-reflowing pre-21376 ConPTY normal buffer. At each checkpoint
every SerializeAddon build under test serializes it, and each output is
replayed into a fresh terminal of the same size and compared cell by cell,
plus cursor, active buffer and modes.

CI runs 25 seeds per mode against a pinned list of pre-existing divergences,
and replays the committed PTY transcripts (the existing agent fixtures plus new
vim, less, pico, and OpenCode captures) under four resize schedules. Point
ORCA_OLD_SERIALIZE_ADDON at a previous patched build to also check byte
identity when no line is wider than the grid, and that no checkpoint regresses.

* test(terminal): build the differential serialize baseline from any git ref

config/scripts/build-serialize-addon-at-ref.mjs reverse-applies the patch that
produced the installed @xterm/addon-serialize dist, applies the ref's patch,
and verifies each step against the patches' blob hashes, so the fuzz can use
origin/main (or any fix commit) as its baseline without a second install.
ORCA_NEW_SERIALIZE_ADDON swaps in a built dist for the build under test, and a
seed-pinned test replays the nine I3 regressions found against origin/main.

* fix(terminal): serialize a clipped wide glyph as a width-1 blank

The stand-in for a wide glyph clipped by a column shrink came from getNullCell(),
whose width is 0. _nextCell skipped it as a wide trailer, and the row-end wrap
check counted the width-0 _backgroundCell as content, so a soft wrap after the
clipped column was taken as natural and replayed one column early
(conpty seed 1149: `abcdefghi中WRAPPED` at 12 -> 10 cols replayed as
`abcdefghiR`/`APPED`). Blank the cell in place instead: width 1, no codepoint,
its own attributes, so it counts as one empty column and forces the wrap.

Differential sweep vs origin/main, 7000 cases per mode: I1 0 byte diffs, seed
1149 fixed; the remaining I3 regressions are the trailing background-row seeds.

* test(terminal): neutral paths in the serialize fixtures and usage comment

The OpenCode transcript carried this machine's lane paths in its footer; replace
them with same-length neutral paths so the recorded cursor layout is unchanged.

* fix(terminal): keep trailing background-only rows when serializing

Without scrollback, _serializeString trims rows after the last content cursor.
A row made only of background-colored blanks emits its erase in _rowEnd but
never moved that cursor, so two or more such rows at the bottom were dropped
(4x3 `r1\r\n\e[48;5;157m\e[J\e[0m\e[3;1H` replayed with the last row blank).
Track the erase separately and extend the kept rows to it, except when the
cursor is wrap-pending: relative moves back from those rows cannot re-create
that state, and doing so regressed normal 508, alt 1425/6647, conpty 4699.

Harness: I1 now exempts checkpoints with a background row after the last text
row, the one place this fix changes bytes on purpose (scope helpers move to
serialize-grid-variant-scope.ts); conpty seed 5 leaves the pinned pre-existing
list. Sweep vs origin/main, 7000 cases per mode: I1 0, I3 regressions 0;
fixed/both-fail normal 967/3914, alt 2628/8316, conpty 6657/2937 (was
323/4558, 2296/8648, 5466/4128).

* test(terminal): narrow the I1 carve-out to where the serializer keeps background rows

Trailing background-only rows change bytes only when the serialized range has no
scrollback (the trimming path) and the cursor is not wrap-pending; checkpoints with
scrollback or a wrap-pending cursor are held to byte identity again. The 7000-per-mode
sweep against origin/main stays at I1 0 and I3 0.

* test(native-chat): pin that screen scrapers ignore kept background rows

The serializer now keeps trailing background-only rows, so a painted TUI's screen
read as text ends in \r\n\x1b[NX rows. Serialize the same frame with and without
them and check the Claude option scrape, the empty-prompt check and the fork
transcript read the same thing.

* test(terminal): read xterm core internals through a checked parser, not Reflect.get

The fuzz oracle reached xterm's private _core with Reflect.get, which the
low-evidence gate rejects. Narrow _core, writeSync and the DECSTBM bounds with
in/typeof checks into one named XtermCoreInternals shape instead.

* test(terminal): keep captured serialize transcripts byte-exact on Windows checkouts
2026-09-25 01:18:14 -04:00
Jinwoo Hong a05649de91 fix(terminal): prove an idle Git Bash prompt through its bin launcher (#22752)
* fix(terminal): prove an idle Git Bash prompt through its bin launcher

Git for Windows' bin\bash.exe is a launcher that runs usr\bin\bash.exe as a
child and waits, so an idle Git Bash pane's job always holds two pids and the
Windows shell proof never confirmed it. Accept exactly the launcher plus its
direct bash.exe child, checked against the identity process table.

* test(terminal): wait for the Git Bash prompt before asserting the hand-off job

* fix(terminal): prove a Git Bash prompt as one unbranched MSYS bash chain

Orca launches Git Bash as bin\bash.exe -c "chcp.com ...; exec \"$BASH\" ... -i",
and each MSYS exec leaves its pre-exec process alive as a stub, so an idle
pane's job is launcher -> stub -> interactive bash. Accept any job that is one
parent-to-child chain rooted at the launcher whose every later member is
bash.exe, instead of a fixed two-process shape.

* ci: register the Git Bash shell-proof win32 test in the package-test list

* fix(terminal): read the spawned shell as a path, and keep one shell map

A spawned shell path with a space (/Users/John Doe/bin/zsh) was split as a
command line, so the POSIX proof compared against "john" and never
confirmed. Local panes now keep only the spawned shell path and derive the
name from it; the Git Bash chain walk drops guards the member check already
covers.
2026-09-25 01:00:36 -04:00
Jinwoo Hong f559c0588a fix(terminal): ground a program that dies with input modes armed on the normal screen (#22739)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(terminal): one process-boundary ground for every known or proven boundary

Three copies of the "the process that armed these modes is gone" reset had
drifted: the cold-restore seed cleared only pen and mouse, the recovery
barrier used the renderer's dead-TUI profile, and the cold-restore payload
had none. A cold restore therefore left the dead process's focus reporting,
bracketed paste, application cursor and keypad modes armed in the live
emulator, the first checkpoint, and main's mirror. And the seed wrote the
dead process's torn escape after the reset, so the new shell's first bytes
could complete it (for example retitling the pane).

PROCESS_BOUNDARY_GROUND replaces them: CAN, leave the alt screen without
moving the normal-buffer cursor, every mouse protocol and encoding off,
focus/paste/app-cursor/keypad off, cursor shown and style reset, kitty
popped, SGR reset, grounded DECSC. It stays inert for the lifecycle
scanner. The seed, the recovery barrier, and the cold-restore payload all
use it, and the seed no longer carries the torn tail.

The first fold after a cold restore now always rebases on live, because
focus and keypad are not in TerminalModes and the zero-record shortcut
could not see them differ.

* fix(terminal): ground a program that dies with input modes armed on the normal screen

The daemon's in-stream crash detector only fired when a program died with
the alternate screen up. A normal-buffer program that armed mouse tracking,
focus reporting, keypad or kitty keyboard flags and exited without
disabling them was cleaned up only in the renderer, so the daemon kept the
modes and re-armed them on the next reattach, mobile included (#13077's
garbage-at-the-prompt family).

The lifecycle scanner now tracks armed input modes (mouse protocols and
encodings, ?1004, ?66, and kitty flags as per-screen stacks that mirror
xterm's main/alt swap and its 16-entry cap). ?2004 and ?1 are excluded:
shells arm them at their own prompts. Modes armed when a command starts
(OSC 133;C) count as the shell's, so a prompt that leaves modes on never
triggers. At OSC 133;D the trigger is now "alt screen or a program-armed
input mode", still one-shot and still gated by the shell proof, and the
existing PROCESS_BOUNDARY_GROUND is recorded through the stream so live
and durable history change together.

WSL panes spawn wsl.exe, which the shell proof does not recognise, so the
detector never grounds them; a test pins that and the renderer keeps
covering them. The mouse-leak e2e now keeps its arming process alive until
the live pane is checked, because the daemon grounds a proven exit.

* fix(terminal): keep shell- and host-armed input modes through the process-boundary ground

ConPTY arms focus reporting (?1004h) before the first prompt, and the live
recovery ground cleared it for the rest of the pane. The barrier now re-arms
the modes that were on at OSC 133;C right after the ground, so only the dead
program's modes are reset.

* fix(terminal): re-assert only modes the shell or host armed outside a command

A mode a program leaked past a refuted proof was still on at the next OSC
133;C, so the baseline snapshot re-armed it after a later ground. Record who
armed each mode instead: only enables outside a command (before any marker,
or between 133;A/D and C) form the baseline.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* test(daemon): follow the isFirstTake rename in the process-boundary ground suite

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.

* test(daemon): pass pendingRecords to the process-boundary ground fold

* fix(terminal): reset alt-screen kitty flags in the process boundary ground

Kitty keyboard stacks are per screen, so resetting only after ?1049l left
a dead TUI's alt-screen flags for the next alt-screen app. Also drop the
inert CAN from the ground (every site grounds after complete bytes) and
correct two stale comments.

* fix(terminal): track input-mode ownership in one map

Each armed mode now has one owner: host (before any marker, or a prompt a
133;C proved), prompt (unproven until C), command, or stale (left past a D).
Host arming is sticky, 133;D demotes command modes (the one-shot), and the
ground re-asserts only host modes. Fixes a D without C triggering on host
modes, an ESC c mid-command turning later enables into host modes, and a
program's repeated host enable dropping host ownership. The reattach e2e now
keeps the arming program alive so only the reattach reset can disarm it.

* refactor(terminal): stop treating kitty flags as host state

fish, the one shell that pushes kitty flags at its prompt, pops them before
running a command and re-pushes at the next prompt, so the ground never needs
to restore them. Only host private modes are re-asserted now.

* fix(terminal): keep host input-mode ownership across RIS

ConPTY answers a mid-command ESC c by re-sending ?1004h, which reset() had
recorded as the command's, so the ground turned host focus reporting off for
the rest of the pane. RIS now drops only non-host ownership.

* fix(terminal): let only the host own focus reporting and leave it in the ground

Host ownership covered every mode armed before the first marker, so a tmux
that died with mouse on had it re-armed by the ground. And the re-assert's
?1004h enable made the runtime's ownership mirror revoke, so remote owners
never settled on Windows. Only ?1004 can be host-owned now, and the ground
skips its ?1004l instead of turning it off and back on, so injected bytes
carry no enables.
2026-09-25 00:50:04 -04:00
Brennan Benson 58ba75b5a5 feat(agent-status): child work records say what the child is doing, how it ended, and when (#22521)
* test(agent-status): pin the legacy child-work projection of published background tasks

* feat(agent-status): child work records say what the child is doing, how it ended, and when

A child-work record gains the facts every surface needs from one host-owned
record: the child that owns it (parentChildWorkId), whether the provider said
it may outlive its launch turn (residency, host-only), what it is doing now
(operation, with an open/reported basis), the newest thing it said
(lastMessage), and when its current invocation settled (settledAt, stamped by
admission, never by a producer).

The codec enforces one membership x state legality matrix: live work is never
done and carries no outcome or settle time; only a shell or monitor stores
monitoring; settled work is done with an outcome and a settle time inside its
own evidence window; an operation exists only while live and working, waiting
or blocked. A settled record written without an outcome reads as unknown, never
success. Malformed descriptive fields drop and keep the record.

A new read-only view (AgentChildWorkView) is the one projection surfaces read;
the legacy subagent and background-task shapes are derived from it with
today's output unchanged for today's inputs. deriveAgentChildDisplayState
folds a child's own state and the liveness of the work it owns through the
same fold a parent row uses, so a child whose own work is idle or done reads
monitoring while a shell it launched runs.

Codex children get a thread_id alias kind.

* fix(agent-status): an unknown child ending can gain its real outcome; operation clock clamped

A settled child whose ending was first recorded as unknown (a roster omission
can land a tick before the frame naming the outcome) now accepts the definite
outcome for the same invocation and keeps its original settle time. A definite
ending still never changes, and a later unknown ending is ignored rather than
downgrading it.

Admission clamps operation.observedAt into the child's evidence window, so an
operation stamped in provider time is kept instead of silently dropped.

The record codec is pinned as host-internal: it rejects a whole record over one
unknown key, so a ratchet test fails if anything outside the host store and
admission path imports it.

* test(agent-status): pass the fold-parity input as a value; name the hook lane's alias kinds

* fix(agent-status): a sparse child observation never erases what the record already knows

Admission merged a later observation by replacing the whole record, so an
ending that knew only that the child was gone dropped its name, model and
token count, and an outcome refinement dropped the recorded last message.
Labels now fill or replace but never clear, tokens never shrink, and a
settled ending keeps its last message unless new evidence carries one.
The provider-id preference is keyed by alias kind so a new kind cannot
compile without a rank.

* fix(agent-status): a child's owner, residency and last message outlive a sparse observation

A settle that knows only that the child is gone dropped who owned it and
whether it ran in the background, and the last thing the child said while
live. They now survive like the labels do: the last message for its
invocation, owner and residency for the child.

* fix(agent-status): an unknown ending keeps a definite outcome and still lands its evidence

A settled child's later `unknown` (or omitted) ending was acknowledged without a write, so a
late last message, token count, alias or reclassification it carried was dropped while the
caller was told it was accepted. The outcome now merges like every other sparse fact: an
`unknown` claims nothing and keeps the stored definite outcome, and only a different definite
ending conflicts.

* fix(agent-status): group child aliases and owned work in one pass

Appending by spread copied each bucket on every insert, quadratic in a bucket's size on the
projection and per-row liveness paths.

* fix(agent-status): group child aliases and owned work without Map.groupBy

The relay runs this core on Node 18, which lacks Map.groupBy; a plain loop into a Map is
equally linear and portable.

* fix(agent-status): a child's activity and last message survive the codec

Admission folded raw provider text with the status-row normalizer, which can leave a tab or
other control character and can end a truncation on a space. The record codec drops such a
field, so a long command cut at a space, a tab in a command, or an escape in a message
silently erased the child's current operation or last message. Admission now folds control
characters to spaces and trims the cut, with the codec's own control-character predicate.

* refactor(agent-status): parse child facts, merge, then check the record

Admission merged provider values before anything knew they were valid, and
the codec then either rejected the whole record or silently dropped the
field depending on how old the field was. A malformed owner erased the
stored one, a label cut on a space rejected the announce, and a bad token
count blocked a settle.

Admission now parses every descriptive fact into a value the codec accepts
or "not said", merges it over the stored record with one rule per fact (a
typed map, so a new request field without a rule fails to compile), and the
codec checks the result. Text goes through one normalizer and the codec
accepts exactly its image; any value outside it is a writer bug and
rejects. The owner is now a fact of the invocation, like the last message.

* refactor(agent-status): name each erasure row by what makes its value malformed

* refactor(agent-status): pin the token parse where the max-merge cannot hide it

* fix(agent-status): provider timing lasts only for its own run

providerTiming records the provider's start and end of one run. Keeping it
across a resume left a live restarted child claiming the previous run's
completion time. It now follows the owner and last message: kept within an
invocation, reset by a new one.
2026-09-24 21:44:35 -07:00
Brennan Benson eb746a6d32 fix(codex): a Codex native chat that never sent a message reopens after restart (#22639)
* fix(codex): start a new thread when a chat's thread was never saved

A structured Codex chat records its thread at create time, but Codex writes
no rollout until the first input. After a restart, launch resumed that
thread, Codex answered "no rollout found for thread id", and the chat could
never run again.

When the head of the handle chain is the session's own creation and Codex
answers that exact error for that exact thread, start a new thread instead.
The new link supersedes the unsaved creation in place and names it, so the
chain keeps one live identity and does not grow across restarts. A thread a
resume, fork or adoption proved is never superseded, and no other resume
error starts fresh.

* test(codex): build launch-resolution chains without a type assertion

* fix(codex): match only Codex's own no-rollout text, pinned through the real connection

The fallback matched Orca's own error-wrapper prefix too, and every test built
that string itself, so rewording the wrapper would have disabled the fallback
with the suite green. Match the method, code -32600 and Codex's exact detail
as the message suffix, and drive Codex's raw error frame through the real
connection in a test.

The link builder now refuses, at the type level, a supersession on an adopted
or resumed link, which the chain would reject downstream anyway.

* docs(codex): note why the no-rollout text is safe on the resume path
2026-09-24 21:43:04 -07:00
Brennan Benson a0e24905f6 fix(agent-status): a cancel never hides live work (#22476)
* fix(agent-status): a cancel never hides live work

After the user cancels a turn, a background shell, scheduled check or
subagent that is still running keeps reading as it truly is in both
lanes. The fold no longer takes a verdict input; the cancellation
survives only as lead.outcome, restated as the row's interrupted flag on
a settled row for readers that predate lead.

* fix(agent-status): keep a cancel's verdict and clock on every settle path

A Grok turn cancelled while a task ran now reads monitoring, and the
idle_prompt backstop that later settles it restated done without the
row's `interrupted` flag, so notification readers announced the
cancelled turn as a clean finish. Derive `interrupted` from the main
agent's outcome, as the Claude builder already does.

The inferred Claude cancel now folds through the host's local main
agent record, which a relayed pane never refreshes, so a second cancel
on an SSH pane inherited the first cancel's clock. The caller admits
only a working main agent, so the cancel always starts a new done clock.

* fix(agent-status): keep the shell fact on an inferred cancel so restart can seed it

An inferred Ctrl+C cancel beside a working subagent publishes a row held
open by child work, but the synthesized event dropped the row's paired
claudeRunningNonAgentTask fact because mainAgent changed. Hydration seeds
a settled main agent only when that fact says no shell ran, so after a
restart the child's drain left the row working with no mainAgent. Carry
the fact forward: a cancel does not change what the shell inventory said.

* fix(agent-status): a Ctrl+C at an idle main agent's prompt cancels nothing

Every row that publishes the main agent fact now admits an inferred cancel
only while that main agent is working. Grok's Ctrl+C at the idle prompt
leaves its background task running, so settling the monitoring row to
done hid live work. Rows without the fact keep the evidence guard, and
Codex keeps it too because its synthesized row is a plain done.

* fix(agent-status): fold a relayed pane's cancel from its row, not the desktop's records

The inferred Claude cancel read and wrote the desktop's own listener
records for every pane. For an SSH pane those records are not the relay's:
hydration seeds them from the saved row and nothing reaps them, so a
subagent that finished on the remote after a desktop restart kept a
cancelled row spinning with nothing running. A local pane still records
the verdict on its listener and folds its own roster; a relayed pane
folds only the child work its row carries. The relayed-pane parameter and
forced clock the shared record path grew for this are gone.

* fix(agent-status): hold a cancel verdict in the store until a new turn or the provider's own

A relay never learns of the cancel the desktop infers from Ctrl+C, so its
next child hook or reconnect replay restated the main agent as working and
flipped the row back. The late-hook suppression that guarded this keyed on
a done row flagged interrupted, which a cancel held open by a shell or
subagent no longer is; it also dropped Grok's own stop_cancelled when the
inference won the settle race, hiding the task that hook reported.

The suppression is replaced by a latch derived from the row: its main
agent reads cancelled (or, from an older host, a done row flagged
interrupted). A settled incoming main agent, another prompt, an explicit
prompt or a session start releases it. Child and replayed events keep the
latched main agent and are re-folded with their own child evidence; late
main agent work is held as before, and Codex keeps its record re-mark.

* test(agent-status): pin Codex's evidence guard beside the main agent fact

* fix(agent-status): a prompt submission ends the cancel verdict latch

The task notification Claude starts when background work ends is a real
turn, but it keeps the cached prompt and carries no explicit prompt, so
within 15 s of a cancel the latch held its prompt submission and every
tool event after it: the turn read as monitoring under a cancelled main
agent until its Stop. The captured shell cancel has exactly this: the
notification lands 0.17 s after the cancel key.

* fix(agent-status): derive a Codex row's interrupted flag from its main agent

The cancel verdict latch lets any settled mainAgent through, so a late
root Stop after an inferred Codex cancel now applies where the old
same-prompt window held it. It restates the cancellation on mainAgent
but, unlike Claude and Grok rows, carried no interrupted flag, so mobile,
the dashboard and notification text read the cancelled turn as finished.
Codex rows (local and relayed) now derive the flag from the main agent
record, like the other providers that publish one.

* docs(agent-status): describe cancel admission for every provider and the store's cancel-verdict hold

* docs(agent-status): correct the idle-prompt Ctrl+C claim to the measured CLI behavior

* fix(agent-status): preserve waiting relay children on cancel

* fix(agent-status): resolve the cancel hold before a child's permission card adopts a relayed main agent

The permission-card hold took the incoming event's mainAgent before the cancel hold ran,
so on an SSH pane a child's next tool under a sticky card restated the relay's stale
working main agent and dropped the cancellation the desktop had inferred.

* test(agent-status): pin that a cancelled turn's drained subagent settles as stopped, not completed

* fix(agent-status): keep a cancel through a restarted relay's child hook and a teammate's idle

A relay that restarts after a desktop-inferred cancel has lost its prompt cache,
so the child's next hook arrived with an empty prompt, read as a new turn, and
replaced the cancelled main agent with none; the row then stayed working after
every child stopped. A child's empty prompt is now unknown, not another turn; a
non-empty different one still releases, since it is the listener's newer prompt.

TeammateIdle names its child by teammate_name and carries no agent id, so the
latch treated it as the main agent's and let the late-hook window apply it after
15 s, reviving the cancelled turn. It is now re-folded as child work.
2026-09-24 21:24:59 -07:00
Jinjing b419b3183e test: remove redundant mobile and GitLab checks (#22748) 2026-09-24 20:45:15 -07:00
Jinwoo Hong fe46138716 fix(terminal): one process-boundary ground for every known or proven boundary (#22735)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(terminal): one process-boundary ground for every known or proven boundary

Three copies of the "the process that armed these modes is gone" reset had
drifted: the cold-restore seed cleared only pen and mouse, the recovery
barrier used the renderer's dead-TUI profile, and the cold-restore payload
had none. A cold restore therefore left the dead process's focus reporting,
bracketed paste, application cursor and keypad modes armed in the live
emulator, the first checkpoint, and main's mirror. And the seed wrote the
dead process's torn escape after the reset, so the new shell's first bytes
could complete it (for example retitling the pane).

PROCESS_BOUNDARY_GROUND replaces them: CAN, leave the alt screen without
moving the normal-buffer cursor, every mouse protocol and encoding off,
focus/paste/app-cursor/keypad off, cursor shown and style reset, kitty
popped, SGR reset, grounded DECSC. It stays inert for the lifecycle
scanner. The seed, the recovery barrier, and the cold-restore payload all
use it, and the seed no longer carries the torn tail.

The first fold after a cold restore now always rebases on live, because
focus and keypad are not in TerminalModes and the zero-record shortcut
could not see them differ.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* test(daemon): follow the isFirstTake rename in the process-boundary ground suite

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.

* test(daemon): pass pendingRecords to the process-boundary ground fold

* fix(terminal): reset alt-screen kitty flags in the process boundary ground

Kitty keyboard stacks are per screen, so resetting only after ?1049l left
a dead TUI's alt-screen flags for the next alt-screen app. Also drop the
inert CAN from the ground (every site grounds after complete bytes) and
correct two stale comments.
2026-09-24 23:38:53 -04:00
Jinwoo Hong 1c1b7829ec fix(daemon): rebase durable checkpoints on the live terminal (#22732)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.
2026-09-24 23:09:36 -04:00