Commit Graph
7 Commits
Author SHA1 Message Date
Brennan Benson 98584332a3 fix(native-chat): record which Codex agent produced each journal row (#22532)
* fix(journal): a batch revision restates the producer of each row it revises

The reducer rebuilds a row's producer linkage from its NEWEST revision, and
absence is a positive claim: no agent id means the session's own agent wrote
the row. So any revision written without the stamp hands a subagent's row
back to its parent, permanently.

Three host paths revise rows they did not write, from the render item they
already hold, and all three dropped the stamp:
- answering a prompt re-appended the asker's row with the fence only;
- dead-generation settlement failed running tool calls and cancelled pending
  prompts through a lifecycle batch;
- stale-session settlement on acquire cancelled lost prompts the same way.

The batch path could not carry a producer at all: linkage was removed from
the batch row because one row covers N mutations, with a note that a mixed
batch would have to stamp per mutation. Dead-generation settlement is such a
batch already, and Codex settlement is about to become one. So each item
mutation now names its own producer, inline like the row base. A mutation
that names none falls back to the row's linkage, which is what a batch read
before. Parse sanitizes a bad per-mutation id the same way it does a row's:
the field is dropped and the mutation kept.

No schema version bump. An older host's mutation validator ignores unknown
keys, so it reads a stamped mutation as the session's own, which is exactly
what it shows today. Old journals carry no stamp and read as before.

Turn revisions still carry nothing: a turn is the session's unit of work,
and the live-turn scans rely on a turn row never carrying linkage. The note
recording that invariant is updated to the new write sites.

* fix(native-chat): attribute a Codex subagent's journal rows to the subagent

Codex journals every thread on its app-server connection into the session's
journal, and a spawned subagent's items arrive on the child's own thread.
None of those rows carried producer linkage, so under the journal's rule that
absence means the session's own agent wrote a row, every child's command,
message, reasoning, prompt and status row read as the PARENT's: the parent
could show its child's running command, its child's reasoning as "thinking",
and its child's prose as its own latest line.

The Claude lane's model is reused, not reinvented: the same fields and the
same absence rule. What differs is how the producer is known. Orca opens
exactly one thread per app-server, so any other thread is one Codex spawned.
That decides WHETHER a row is a child's from its first frame, announced or
not, and the thread id is final at once: it is never re-minted the way a
tool-call reference is, so no correction ledger is needed for identity.

- agentId: the child thread id, the same id the status side keys a Codex
  child on.
- parentAgentId: the thread whose stream carried the child's `started`
  activity. Codex emits that item on the spawning agent's own session, so a
  child that spawned a grandchild is named; the session's own thread is not.
  Other activity kinds ride whichever agent acted and are not used.
- producerKind: 'agent'.
- attempt: which run of the child the row's own turn was, counted from the
  child turns the roster already observes; absent on the first run. Taken
  from the row's turn rather than the child's latest, so a persistent shell
  that outlives its turn keeps its run across revisions.
- providerParentRef is omitted: a Codex child's frames carry no parent
  reference of their own beyond the thread id, which is already agentId.

One resolver, owned by the roster (which already owns what is known about
each child thread), is handed to every writer: items, streams, generic and
summary rows, prompts, compactions, goals, and the three settlement batches.
The session-end settlement mixes every thread's rows in one batch, so each
mutation names its own producer. Turn rows stay unstamped: Codex writes them
only for the primary thread.

The spawn-group roster row stays unstamped on purpose: a child's frame can
trigger its write, but it is the parent's list of its children.

The translator's construction moves to a parts module so the translator
stays a router under the line cap, and the item streams reuse one
append-and-publish helper instead of two copies. Children are never swept
at turn end; nothing here changes that.

* test(native-chat): pin Codex subagent attribution at every writer and every parent reader

Two layers, so a stamp that is correct in the store and never read, or read
and never persisted, cannot pass.

The readers, through the real path: translator, deferred sink, on-disk
journal, snapshot. Each is a defect on main: the parent named its child's
running command as its own tool, read its child's reasoning as itself
thinking, showed its child's compaction as its activity line, and quoted its
child's prose as its latest line (checked after closing and reopening the
journal, so the stamp is read back from disk). The transcript still renders
the child's rows.

The writers, through a sink that records the linkage of every plain append,
batch mutation and lifecycle transition: start, streamed checkpoint and
completion of one command all restate the child; a row that beats the spawn
announcement is still the child's; a grandchild names the child that
announced it, while an `interacted` activity names no parent; a follow-up
turn is the child's second run, and a shell that outlives its turn keeps its
own; the exit batch settles each thread's rows under its own producer and
the turn row under none; a child's provider frames, approval and goal rows
are its own; nothing is stamped while the session thread is still opening;
and the spawn-group row stays the parent's.

* test(native-chat): pin linkage forwarding on the sink's lifecycle-transition path

A Codex child's goal row is written through a lifecycle transition, so a sink
that forwarded only the fence there would file the child's goal as the
session's own.

* test(native-chat): type the Codex item fixtures as thread items

* refactor(journal): keep a row's producer across revisions that name none

The reducer took a row's producer linkage from its newest revision, so every
writer that revised a row it did not write - a prompt answer, a dead-generation
or stale-session settlement, the reopen sweep of stale subagent rosters - had to
restate the producer or silently hand a subagent's row to the session's own
agent. Three of those writers had been patched to restate it; the next one to
forget would reintroduce the bug.

Attribution is now fixed by a row's first write. A revision that names no
producer keeps the row's existing linkage; one that names any replaces the
whole bundle, which is how a provisional stamp is still corrected in place. A
row re-created after a tombstone starts with nothing. The reducer runs the same
fold on replay, so the kept producer survives a reopen.

The three restatements are removed. Per-mutation linkage on lifecycle batches
stays: a batch can create a row (a Codex child's prompt, or a child's item
settled before any checkpoint landed) and one batch can mix producers.

* test(journal): pin producer inheritance in the reducer and across a reopen

A revision naming no producer keeps the row's, on the plain item path and in
a batch settling a child's row beside the session's own; one naming any
replaces the bundle wholesale; a tombstone clears it; a stale revision cannot
touch it; and a reopened journal replays it exactly as it was folded live.

* refactor(codex): name the translator's writer factory for what it builds

* docs(codex): say why a settled row names its producer

* test(journal): drop a producer test the stale-revision guards make unreachable

The stale revision is dropped whole by two independent guards before the
inheritance rule runs, so its producer assertion could never fail; the
reducer's own stale-revision tests already cover the drop. Also say what
the batch sink does forward: each mutation's own producer.
2026-09-24 13:33:56 -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
Brennan BensonandMerge Sim f4c2821167 refactor(agent-session-journal): move the session journal onto SQLite (#18652)
* refactor(agent-session-journal): move the session journal onto SQLite

The agent-session journal kept its state in three hand-rolled file formats: an
append-only `log.jsonl` with torn-tail repair, a `snapshot.json` holding folded
state plus a retained tail, and byte-quarantine files for anything unreadable.
This replaces all of it with one SQLite database per session — `journal.db`
beside the existing `blobs/` store — using the in-house adapter and the
open/pragma/migrate/harden pattern the orchestration database already follows.

Two tables: `journal_rows` (the append-only log, keyed by
`(session_id, epoch, seq)`) and `journal_sessions` (the derived projection,
upserted in the SAME transaction as every insert). Rows stay JSON in one
column, so the row schema, the version upcast chain, and the reducer survive
byte for byte — `journal-reducer.test.ts` and four other suites pass unchanged
and are the regression proof.

Deleted: `journal-log-file.ts`, `journal-compaction.ts`,
`journal-corruption-quarantine.ts`, and the public `compact()` /
`compactionBoundary` / `autoCompact` members, none of which had a non-test
caller.

Existing `log.jsonl` / `snapshot.json` journals are deliberately abandoned. No
importer: a session created on the old path stops working, which is acceptable
because the feature is off by default.

## The physical quota is repriced, because SQLite does not charge like a file

The 256 MiB per-session bound is unchanged, but the arithmetic under it could
not survive: SQLite grows the database in pages and the WAL in frames, and the
checkpoint that copies the WAL forward holds the same pages in both files at
once, so a transaction's peak is about twice its content. Admission now charges
the candidate transaction's own measured page cost, validated against a sweep
that runs as a regression test (`journal-database-space.test.ts`) rather than
derived from reasoning about the allocator.

Four things are load-bearing rather than tuning, each measured:

- `auto_vacuum = INCREMENTAL` must be set BEFORE `journal_mode = WAL`. Set it
  after and it is ignored with no error, reclamation silently becomes a no-op,
  and the file never shrinks again. Both halves are asserted.
- `wal_autocheckpoint = 0` plus an explicit `wal_checkpoint(TRUNCATE)` at the
  end of every write path, so the one moment the same pages live in two files is
  a moment the charge accounts for.
- Reclamation runs in bounded chunks. A single unbounded `incremental_vacuum`
  took a 252 MB directory to 504 MB — the reclamation added to defend the bound
  would have breached it. `PRAGMA incremental_vacuum(N)` also frees exactly one
  page unless it is stepped to completion, which no size assertion catches, so
  the freed page count is asserted directly.
- A blocked checkpoint leaves the WAL on disk together with the database growth
  it already copied, so admission charges that deferred copy explicitly. The
  term is zero whenever the last checkpoint succeeded, so the uncontended path
  admits and refuses an identical set.

The epoch discard is `DELETE FROM journal_rows` with no WHERE clause, which
takes SQLite's truncate optimization: measured at ~0.26% of the database in WAL
bytes where the `WHERE session_id = ?` form rewrote every emptied leaf at up to
99%. One database per session is what makes the unqualified form correct.

An open, empty journal costs 57,344 bytes before a single row exists, so a
configured quota below `JOURNAL_MIN_SESSION_BYTES` now fails loudly at open with
the existing `journal_bound_exceeded` instead of as a run of identical append
failures. No production caller configures one; the affected surface is test
fixtures, rescaled to the smallest value that restores what each case proves.

## One deliberate behaviour change

Compaction was the only mechanism that shed bytes inside an epoch, and the write
path called it precisely so an append at the bound was not refused. The
SQLite-shaped replacement — a bounded prefix delete — cannot be used: with the
snapshot gone the surviving rows ARE the state, so dropping the oldest of them
loses the oldest transcript silently at the next reopen. So no row is ever shed
inside an epoch, and a session whose row bytes alone reach the bound now refuses
every append where it previously compacted and continued. A loud typed refusal
beats silent data loss.

What still sheds is unreferenced BLOB bytes — the dominant and unbounded byte
source — on the same write-path hook. The escape from the hard stop is the fold
that already exists, `replaceEpochItems`, which now actually returns bytes to
the filesystem instead of leaving them on the freelist.

The prune's protected set is a union of live reducer digests AND the candidate
row's own digests, including those cited only by a nested lifecycle-batch
mutation. Content addressing never rewrites a digest already on disk, so
protecting live state alone deletes the blob the append is about to cite — a
dangling reference that surfaces one reopen later as an empty expansion on an
item the user can see. `journal-store-blob-budget.test.ts` pins it, and it goes
red when the set is narrowed back.

## Handle ownership

A file handle used to be opened and closed per append; a SQLite handle is held
for the session's lifetime. Every path that can open a connection now has one
owner: the open function owns its raw connection until it returns, the store
owns its retained one and releases it in a new `close()`, and every other
connection is closed by the call that opened it. The attach, recovery,
eviction, map-overwrite and host-teardown paths close what they drop, and host
teardown is failure-complete — the sink-barrier flush throws by design, so a
trailing close statement would be skipped on exactly the path that leaks.

`close()` has a stated contract: admission at enqueue and permanent, the close
step on the same queue past that gate, one shared in-flight attempt, fulfilment
terminal, and the release last and deliberately unguarded so a retry re-enters
it. Guarding the release would skip it on retry, guaranteeing a permanent leak
in exactly the case where it did not release.

`journal_closed` joins the error union for a write after `close()`; no file
outside the directory references any of these codes.

* fix(agent-session-journal): make a COMMIT final, stop repairs deleting valid rows, and keep rejected closes retryable

Six review findings on the SQLite journal migration.

1. A successful COMMIT is now the point of no return. The ordinary append,
   the epoch roll and the epoch replacement each adopt the committed row or
   epoch BEFORE any post-commit filesystem work; checkpoint, reclaim, blob
   prune and directory measurement run through `runJournalPostCommit`, which
   is best-effort by design and falls back to the transaction's own charge as
   a conservative footprint. Previously a post-COMMIT scan failure rejected a
   durable append and the next one reused its sequence, and a failed epoch
   housekeeping step left the store writing into a prefix already deleted.

2. Corruption repair preserves instead of destroying. A rejected suffix is
   copied into a new `journal_quarantine` table and removed from the live
   epoch in ONE transaction per chunk, charged against the session bound
   before a byte is written; a journal that cannot afford the copy refuses to
   open rather than falling back to deletion. The repair state is exposed as
   `journal.repair` and the rows are readable through
   `recoverQuarantinedRows()`, so Orca-owned submission, receipt and
   lifecycle identity survives a gap or a malformed row.

3. The physical charge covers the B-tree key payload. `session_id` and
   `epoch` are stored in both tables and both primary-key indexes and appear
   nowhere in `row_json`, so the journal boundary now bounds them and
   `journalTxnPhysicalCost` charges those bounds plus the projection upsert.
   The charge sweep runs the exact production transaction at maximum admitted
   key sizes.

4. A rejected `close()` no longer orphans its handle. Callers hand the
   journal to `agentSessionJournalCloseRetries` instead of swallowing the
   rejection, the attach map replacement is ABORTED when the previous
   journal will not close, host teardown retries what the registry holds, and
   a failed runtime teardown is retained so the next stop is a real retry.

5. `journalWalBytes()` returns zero only for ENOENT and propagates every
   other stat error, so admission and reclamation fail closed.

6. The WAL contention test closes the writer before removing its temp root
   and asserts the directory is removable once handles close.

Regression coverage: post-commit divergence (4), corruption repair (5),
key bounds (5), WAL stat (8), close retry (5), plus a runtime stop-retry
case. Each fix was ablated on this head and the matching tests go red.

* fix(agent-session-journal): anchor replay at sequence 1, make quarantine append-only, and charge it in bytes

Three ways the corruption quarantine still lost rows it was written to keep.

Replay validated contiguity from the first row that HAPPENED to remain, so an
epoch missing only its sequence-1 row declared the leftovers contiguous and set
no `truncateFrom`. The load was still corrupt, so recovery imported provider
history and `replaceEpochItems` deleted every live row — including Orca-minted
submission, receipt and lifecycle identity that no transcript can reconstruct,
and that nothing had quarantined. Replay now anchors at sequence 1, so a missing
epoch row rejects the whole surviving range before any replacement runs.

`journal_quarantine` was keyed on `(session_id, epoch, seq)` and copied with
`INSERT OR REPLACE`. A repair frees the sequences it removed and the live epoch
reuses them, so a second repair in the same epoch silently deleted what the
first preserved. The table is now keyed on a surrogate `quarantine_id`, the copy
is a plain append, and `(epoch, seq)` is metadata; existing v1 databases are
rekeyed in the migration that already bumps `user_version`.

The admission charge read `length(row_json)`, which counts CHARACTERS for a TEXT
value where `journalTxnPhysicalCost` expects physical UTF-8 bytes. A multibyte
suffix was charged at up to a third of what it writes, which defeats the
pre-write physical bound — over a megabyte on a maximum-size lifecycle batch.

* fix(agent-session-journal): keep a repaired epoch anchored and stop the v1 quarantine migration doubling the file

Replay validated numeric contiguity from sequence 1 but never that sequence 1
IS the epoch row. When the anchor was missing the repair set aside every
surviving row, and if provider-history import then failed — a transcript that
is temporarily gone is enough — the journal reopened as a clean, row-less
epoch: an ordinary append took sequence 1, replay accepted it, read-restore
published it as history, and automatic recovery never ran again while the
user's real messages sat in quarantine.

Replay now rejects an unanchored prefix, the open publishes an
`unreconcilable_prefix` anchor for an epoch its repair emptied, and that anchor
keeps reporting corrupt — so provider history is retried on every attach —
until the timeline is rebuilt or the session writes content of its own. A
repair also discloses rows it set aside when no line was unreadable at all,
which is the case that removes the most.

The v1 quarantine rekey copied every legacy row into the new table inside one
transaction and dropped the old one. A quarantine holds whole rejected rows: a
single 8 MiB row nearly doubled the database past the physical bound the open
had already checked, the dropped pages only reached the freelist, and the next
open refused the session it had just migrated. The v1 table is renamed and
frozen instead, and reads take both generations. Table creation also moves
inside the migration transaction, so a crash can no longer leave a v2-shaped
database still reporting version 0 for an older build to write into.

* fix(agent-session-journal): stop an empty provider transcript retiring the repair marker

A transcript that exists but decodes to zero messages was imported as a
success: the import published an empty `legacy_import` replacement that
deleted the `unreconcilable_prefix` anchor and its disclosure, so the next
probe read the session as clean and every later attach skipped provider
recovery while the user's rows sat in quarantine for good.

The import now leaves the epoch untouched when nothing decodes, reporting
`replaced: false`, and recovery treats that like a transcript it could not
read — the marker stands and a later attach with real history rebuilds the
timeline.

* style(agent-session-journal): merge the duplicate journal-database-space import

* refactor(agent-session-journal): drop quarantine, byte bound, blob spill and rate limit

Match what comparable implementations do: the journal is an unbounded
append-only SQLite log with no side tables and no admission control.

Corruption: the rejected suffix is DELETED rather than copied into a
quarantine table. The load still reports `corrupt` and recovery still
rebuilds the epoch from provider history, so the observable outcome is
unchanged — only the preservation half is gone. The schema is back to one
version with two tables; no v1 database exists outside unmerged commits of
this branch, so the rekey migration and the two-generation read path go with
it. Sequence-1 epoch anchoring and the empty-provider-transcript retry are
kept: both are about the corrupt signal being correct.

Size: no `maxSessionBytes`, so no page-cost arithmetic, reclaim band,
incremental vacuum, lifecycle byte reservations or `journal_bound_exceeded`.
`auto_vacuum` and `wal_autocheckpoint = 0` existed only to make a
transaction's physical cost predictable for that charge; with the charge gone
SQLite's default checkpointing is what the journal wants, and the explicit
pre-close checkpoint is redundant with the one `db.close()` performs. WAL,
`synchronous = FULL` and `busy_timeout` stay.

Payloads: an oversized body is truncated at the existing inline cap with the
existing marker and the remainder is discarded, bounded at the translation
layer that already calls these helpers. The truncation point and message do
not change; the content-addressed blob directory and all digest tracking do.

Rate: no `maxAppendsPerWindow` and no `journal_rate_exceeded`.

`JournalPayloadLimits` is now just the inline cap.

* fix(agent-session-journal): mark a partial repair pending and bound multi-block tool input

A repair that keeps its prefix had nothing durable to show for the suffix it
deleted: a sequence gap costs no malformed row, so no disclosure is appended,
and the surviving rows keep their epoch anchor. The next probe read a
contiguous anchored prefix, called it clean, and the deleted stretch of
timeline was never asked for again — silent loss, with the deletion already
committed. The deletion now writes a `journal_repairs` marker in the SAME
transaction, and replay keeps reporting corrupt while it stands. It retires
under exactly the rule the emptied-epoch anchor takes: a fresh epoch carries
the rebuild, or the session writes content of its own past the sequence the
repair left free. The repair's own disclosure is not that content.

Legacy import bounded a tool call's input only when it was the message's sole
block; the multi-block path returned `tool-call` unchanged, so a mixed message
from Claude, Grok or an omp execution cell persisted the whole input despite
`inlineHeadBytes`. `boundBlock` now routes it through `boundToolInput`.

Also drops canonical comments describing quarantine, snapshot files, blob
storage and blob compaction — none of which exist any more.

* fix(agent-session-wire): stop awaiting the synchronous journal probe

loadJournal runs on a sync-database connection and returns JournalLoad | null, so both wire call sites were awaiting a non-Promise. The type-aware code-quality gate flags it; the native gate does not.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 15:09:23 -07:00
Brennan BensonandMerge Sim 872bd51d47 fix(native-chat): reland large structured command results (#17720)
* fix(native-chat): preserve large structured command results (#17707)

* fix(native-chat): preserve large structured command results

* chore: place native chat validation artifacts under docs

* chore: drop stale root package config

* fix(native-chat): enforce rebuilt lifecycle append slots

---------

Co-authored-by: Merge Sim <sim@local>

* chore: omit native-chat reland planning docs

* fix(native-chat): remove journal store import cycle

* fix(native-chat): keep journal factory acyclic

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-31 13:12:19 -07:00
Brennan Benson 894ed75abb Revert "fix(native-chat): preserve large structured command results (#17707)" (#17719)
This reverts commit 5fe37729ea.
2026-08-31 12:34:49 -07:00
Brennan BensonandMerge Sim 5fe37729ea fix(native-chat): preserve large structured command results (#17707)
* fix(native-chat): preserve large structured command results

* chore: place native chat validation artifacts under docs

* chore: drop stale root package config

* fix(native-chat): enforce rebuilt lifecycle append slots

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-31 12:34:03 -07:00
Brennan Benson fd9125ea8c feat(native-chat): Codex structured native chat restructure (#16729)
* feat(native-chat): port structured Codex sessions from restructure-recovery

Rebuilds the desktop structured native-chat implementation from
brennanb2025/native-chat-restructure-recovery (tip 4e31c08db3) on top of
current main as a single commit, scoped to the local Codex path.

Ported:
- Structured agent-session core: durable record store + single-writer lease,
  canonical journal, agent-session wire host/attach/eviction/subscribers,
  `agentSession.*` RPC surface (registered via ALL_RPC_METHODS; host-side
  mobile allowlist included for wire compat), pty write gate, transcript
  additions, and the Codex app-server adapter/launch resolution.
- Renderer: NativeChatStructuredSession view/composer stack, structured
  launch path with the single-flight guard, local structured session tabs
  sync, activation gate + structured inventory (read-only
  `agentSession.handoffStatus` probe), agent-session tabs in the tab strip,
  AI-vault structured session activation, and the settings pane with the
  parent Experimental Chat UI toggle plus the nested "Use updated structured
  native chat" toggle. New sessions require both flags, agent codex, no
  prompt, and a local non-WSL, non-Windows-host execution host
  (structured-native-chat-availability).
- Fixes 72c013cea6 (verified Codex launch recovery), 8ddbaf5e3d (defer
  native terminal view switching affordances), and 4e31c08db3 (release the
  launch gate after a visibility retry) with their regression tests,
  including the third-launch-after-retry guard case.
- Cross-version agent-session wire test + CI lane, packaging entries
  (proper-lockfile, agent-tooling asar excludes), and the wire-compat doc
  section.

Deliberately not ported: mobile/ changes, the Claude structured runtime
(only the claude-transcript-branch-proof and claude-structured-owner-identity
leaf modules remain, backing the kept TUI-recovery arms), the terminal↔chat
adoption/handoff flow (`agentSession.adoptTerminal`/`requestHandoff`, the
handoff request engine, TUI adoption machinery, orca-runtime adoption
methods), renderer switching affordances and their dead leftovers, the
hook/subagent-status refactor cluster, and unrelated branch changes. The
crash-during-acquisition recovery path (restart handoff adjudication,
restore/reverse re-acquire, lease schema handoff keys) is kept because every
plain direct launch depends on it; a trimmed handoff coordinator exposes
only status/restore/close.

Branch edits that targeted files main has since split (ipc/pty.ts,
worktrees.ts, rpc/methods/terminal.ts, useIpcEvents, pty-connection,
store/slices/terminals.ts, runtime-types, web preload) were re-applied to
the split modules, preserving main's newer logic (Windows CIM fallback,
browser tab close rework, cold-restore resume flow, dispatcher threading).

Known seam: the mobile clipboard image-provenance CONSUMER gate ships
(agentSession.send refuses unproven mobile image refs with
agent_session_image_untrusted) but the producer hunk in
rpc/methods/clipboard.ts stays with the unported mobile cluster, so mobile
image sends into structured chat fail closed until that side ports.

* fix(native-chat): trust only authenticated local image uploads

* fix(build): preserve Windows process-tree patch application

* test(windows): include process creation time in addon fixture

* fix(build): run windows-process-tree node-gyp from the physical package dir

gyp expands the node-addon-api dependency by probing node, whose cwd
resolves to the package's physical directory in the store, so the emitted
target is a store-relative ../../../../node-addon-api@... hop. gyp then
resolves that hop against the rebuild cwd; from the node_modules
symlink/junction it escapes the store and configure fails with
"node_addon_api.gyp not found" (run 32999886072).

Rebuild from realpath(package dir) so both bases agree, matching how the
package manager itself runs native install scripts. The regression test
replays gyp's expansion+resolution against the planned cwd and fails
without the fix.

* fix(native-chat): keep chat tabs visible through terminal closes and empty-worktree launches

Two proven blockers in the native Codex tab contract:

closeTerminalTab pre-empted the canonical unified close. With one terminal
left it deactivated the worktree on a terminal/editor/browser-only check,
blanking a workspace that still held a renderable agent-session tab; with
two or more it pre-picked a successor from terminal entities only,
re-stamping the group active before closeUnifiedTab's MRU/neighbor repair
could land on the chat tab. Successor choice now defers to the unified
contract whenever the terminal has a unified row, and deactivation is
gated on the unified renderable count (matching leaveWorktreeIfEmpty),
with the legacy pre-pick kept only for terminals without a unified row.

A structured session created on an empty worktree was published into the
host's headless group while preserveLocalLayout froze the local layout,
leaving the tab in store but permanently off screen. A preserveLocalLayout
owner now always takes client-owned placement — repairing a rendered
leaf whose group record is missing, or materializing a rendered group on a
truly empty worktree — and applies the client-derived layout repair while
still rejecting host-authored layout.

Regression tests drive the real store through closeTerminalTab (git
worktree and folder workspace) and the real snapshot applier for the
empty-worktree adoption states; all fail without the fixes.

* fix(native-chat): close stale turns and retry rejected sends

* fix(native-chat): retire hosted rows on structured tab activation

* fix(native-chat): preserve rpc defaults across main merge

* chore: format remote wire compatibility guide

* test(native-chat): cover retry after unconfirmed send

* fix(native-chat): reload outbox on session switch

* docs(settings): disclose structured chat platform limits

* fix(native-chat): await Codex launch-home preparation

* fix(codex): align child-process allowlist with async trust bridge

* test(identity): update inventory for tab surface refactor

* fix(windows): preserve process-tree CRLF patch sources

* fix(native-chat): anchor an unmatched chat echo where it was sent (#16117)

* fix(native-chat): anchor an unmatched chat echo where it was sent

The reported symptom was old user messages replaying below every new turn, so the
conversation read as scrambled. The cause was not that the echo failed to match a
transcript row. Claude consumes a mid-turn send through a `queued_command`
attachment and writes no `type:"user"` record for it, so some echoes can never
match, and no amount of matching will change that. The cause was WHERE an
unmatched echo rendered: buildMobileNativeChatTransientData appended every pending
item after the entire transcript, so it re-read below each turn that landed
afterwards.

Render each echo directly after the transcript row it was sent against, using the
baseline the send already captures. An unmatched echo is then at worst a duplicate
in the right position rather than a scrambled one, and it stays visible. Echoes
sharing an anchor keep send order; a send with no baseline, or one whose anchor
folding dropped, still falls back to the tail.

Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an
echo can never match, then removing it, loses the user's own text for a message
the agent did receive, and it cannot fire in the common case anyway - measured
drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing
gap: the count pass has no baseline-tail guard, unlike the glue pass, while
`messages` is a 40-row window that head-trims, resets on reconnect and grows at
the front on loadEarlier, so a false landing there would license deleting a
DIFFERENT outstanding message.

That count-pass gap is real and left for a separate change; anchoring makes its
worst case a duplicate in place rather than a scrambled conversation.

* fix(native-chat): preserve folded echo anchors

* fix(native-chat): preserve forward-folded echo anchors

* fix(native-chat): keep leading folded echoes in place

* fix(workspace-cleanup): show git status for every row (#16690)

* fix(native-chat): refuse structured chat on every Windows execution path

canUseStructuredNativeChat only refused win32 when a project runtime
resolved, so folder-workspace keys (and other keys with no project
runtime) failed open into structured chat on Windows. Fail closed on
win32 unconditionally after the host check, matching the settings copy:
local macOS/Linux only; Windows/WSL/SSH stay on terminal chat.

* fix(native-chat): restore runtime refusals behind the win32 gate

506d375de3 replaced the project-runtime checks with a bare platform test,
so a WSL or repair-required runtime resolution would no longer refuse
structured chat off-win32. Keep the unconditional win32 refusal and
re-run the runtime resolution after it, so the gate does not depend on
the resolver's own platform guard. Tests inject WSL and repair-required
resolutions on darwin/linux and fail against the regressed gate.

* fix structured session journal durability

* fix structured tab active pointer after restart

* fix(native-chat): await optional lease renewal callbacks

* refactor(skills): extract install error messages

* fix(agent-session): harden recovery ownership

* fix(native-chat): retain panes across tab activation

* fix(native-chat): address round-one review findings

* test(native-chat): align integration coverage after main merge

* fix(native-chat): harden round-two reliability

* fix(native-chat): harden round-three reliability

* fix(native-chat): close round-four recovery gaps

* fix(native-chat): separate bounded journal key forms

* fix(native-chat): reset outbox error in render on session switch

The switch effect adjusted error state after the sessionId prop changed,
tripping react-doctor's no-adjust-state-on-prop-change on the changed-code
gate and flashing the old session's banner for a frame. Reset it with the
render-time previous-value guard instead.

* fix(native-chat): invalidate stale outbox settlements

* test(native-chat): restore settled-error session-switch regression

a6e2379bd1 replaced this test with the in-flight settlement race test,
leaving the render-time error reset unpinned: deleting the reset block
still passed the whole native-chat suite. Keep both scenarios pinned;
they are distinct (settled error clears on switch vs stale settlement
invalidated in the commit-to-passive window).

* test(wire): make release checkouts race safe

* test(wire): pin cross-process checkout single-flight and importer specifier contract

* test(wire): harden release checkout lifecycle

* fix(build): drop CR-byte residue from windows-process-tree patch

The two trailing CR bytes on the patch's deletion lines are a proven
no-op: pnpm hashes patches CRLF-normalized (both forms hash to the
lockfile's 946ffb2b) and materializes this package without applying the
patch in either form, so the load-bearing build edits come solely from
applyWindowsProcessTreeBuildFixes() (#16947), which handles both source
EOL forms. Restore byte-identity with main and repin the contract test
to the post-#16947 reality: LF-only patch bytes plus lockfile hash sync.

* fix(native-chat): skip empty startup recovery
2026-08-28 16:45:58 -07:00