Commit Graph
72 Commits
Author SHA1 Message Date
NeilandClaude 8de37199e2 fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs (#30942)
* fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs

The adoption rollback ran outside the store's durable transaction, so the store
rebased it like a stale renderer write below the topology fence and kept the
adopted tabs, layouts and incarnations; the next save then persisted them.
Run the adoption through runDurableMutation, whose rollback phase skips that
rebase, and refuse if a queued write changed the session it was checked against.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(runtime): an adoption into an unwritten host partition is not refused as a conflict

Co-authored-by: Claude <noreply@anthropic.com>

* test: run the orphan adoption rollback test in the SQLite runtime project

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(runtime): check and write an orphan adoption inside the store transaction

A snapshot comparison could not see a queued binding write that edits the
session in place. Plan the adoption against the session the transaction
reads instead, so queued writes are validated against rather than guessed at.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-19 19:58:14 -07:00
Brennan Benson 3d0973d0d4 A chat's visuals and sent images live as long as the chat (#30413)
* fix(native-chat): a chat's visuals folder lives as long as the chat

A chat's scratch folder was deleted once the chat had gone 30 days
unused, so an old chat's inline visuals turned into "Visualization
unavailable" while the chat and its history stayed. The common pattern
keeps a chat's files for as long as the chat and never deletes them on
an "unused for N days" timer.

The sweep now removes only folders no chat record maps to, plus
leftover .expired folders older builds parked. Everything that existed
only to make the age rule safe goes with it: the per-folder save lock
and its expiry candidates, the session-lane idle check, the journal
last-activity query, and the migration's fresh-window timestamp. The
visuals skill no longer tells agents the folder is temporary.

* fix(native-chat): an image sent in a local chat lives as long as the chat

A local chat's pasted images lived in one paste folder that deleted
every file older than 30 days, sent or not, so an old chat's images
turned into label chips. Paired-server chats already keep pastes in the
host's attachment store, where the send that names a file claims it for
its chat and only unclaimed files are ever swept: the common pattern.

A local composer paste now goes into this machine's own attachment
store (starting the local chat host first), so its send claims it the
same way. The old paste folder is no longer written or swept: messages
sent before this change may name its files. Restored drafts still read
it.

An unclaimed upload now waits 30 days instead of one before the sweep
takes it. A draft names its image by this path, so the window is how
long an unsent draft keeps its image; 30 days is what local drafts had,
and it outlasts the host's send-id window so a resent draft still finds
its image.

A chat folder an older build set aside as unused (<hash>.expired) goes
back to its chat instead of being deleted.

* fix(native-chat): a terminal-backed chat's pastes are never swept

A terminal-backed chat has no journal, so no send claims its pastes and
nothing can tell a sent one from an abandoned one. Its pastes therefore
stay in the paste folder, which is no longer swept, rather than in the
attachment store, whose sweep would take them after 30 days unclaimed.
Only a local structured chat's paste (forStructuredChat) goes to the
store, where its send claims it.

* fix(native-chat): keep the attachment owner's literal kind for structured local chats

* test(ipc): expect the local chat store option on the clipboard registrar

* fix(native-chat): preserve attachment ownership and restore held files safely

* fix(native-chat): settle restored image checks independently

* test(native-chat): assert restored image outcomes without batching

* Re-check draft images when the attachment sweep removes their upload

A draft checks its pasted images once, when its composer comes back. Every
chat in an open workspace mounts its composer at launch, so that check ran
before the store sweep (60 s after host start, then every 6 h) deleted an
expired unsent upload, and nothing checked again: the chip lost its picture
with no Not kept feedback and Send stayed enabled.

The sweep now reports each removal attempt; the main process forwards it to
its window, and the draft store starts a fresh finite check for every loaded
local draft image in that folder, mounted or not. A notice is a reason to
check, never proof of absence.

* Let the app effect remove the draft re-check listener it adds

startNativeChatDraftLoad dropped the unsubscribe for the upload-removal
listener, so each recovery through the root error boundary's Retry remounted
App and added another listener. Return it so the effect's cleanup removes it.
2026-10-19 15:11:24 -07:00
a1ea2e3978 perf(orchestration): stop recompiling a mailbox lookup on every agent title (#30792)
* perf(orchestration): stop recompiling the remote-attachment lookup on every agent title

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

* chore(orchestration): state why the attachment row casts are safe

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

* fix(ci): run the mailbox owner test in the Node runtime project

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

---------

Co-authored-by: E2E Test <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-19 14:05:16 -04:00
Brennan Benson 53191250e8 Accept messages while any agent starts, and bound each startup step (#28046)
* Finish structured agent readiness before delivering held work

* Publish the Codex process before protocol startup

* Publish ACP agent processes before session startup

* Publish Pi at spawn and settle signed-out starts once

* Adapt permission fixtures to startup lifecycle

* Keep model discovery for chats before their first start

* Use one host readiness promise for provider operations

* Await provider opening in the execution host

* Await Claude initialization in host-owned open

* Observe Claude readiness through the host status

* Keep optional readback behind host readiness

* Recheck launch permission after host readiness

* Preserve the awaited child during permission revalidation

* Await Codex protocol open under host ownership

* Await ACP protocol open in the host

* Derive ACP opening from its protocol session facts

* Exercise unanswered ACP options with an explicit silent route

* Await Pi open and fail signed-out construction

* Read Pi catalog facts from awaited opening

* Keep the Pi fixture exit observer

* Record failed owned opening before the first message

* Abort a matching provider open when its child exits

* Cover provider exit during an unanswered host open

* Cover explicit failure facts on a host-stopped child

* Capture message choices per accepted send and preserve picker state

* Apply accepted Claude message choices at prompt handover

* Apply captured message choices at provider dispatch

* Bind native chat choices to accepted message dispatch

* Reset Codex turn choices for each captured message

* Resolve Codex message defaults from native facts

* Align message choice and Pi default fixtures with provider dispatch

* Align option fixtures with saved message choices

* Match Codex message choices to the active turn origin

* fix: let pending commands reach handover refusal

* Abort pre-prompt message choice writes with host-owned wait

* Verify message choices through provider input boundaries

* Keep legacy mobile test hosts explicit about message choices

* Keep startup waits cancellable and preserve operation order

* Derive Codex startup exits from host phase

* Open Codex protocol in background-task test fixtures

* Keep ACP startup ownership until handshake completes

* Run ACP opening catalog fixtures with Node SQLite

* Verify saved ACP picks without live option writes

* Keep ACP catalog bookkeeping from failing an options read

* Restore signed-out restart and fail Pi startup faults

* Run Pi opening catalog fixtures with Node SQLite

* Preserve message choices across cold clients and kept cards

* Remove the unused provider option restore ledger

* Clarify launch choices and legacy option hydration

* Remove remaining restore-ledger forwarding and assertions

* Remove unused ACP and Pi restore bookkeeping

* Keep live choices and delivery order while agents start

* Drop retired startup revision from Codex opening

* Keep live-option cancellation until message choices and clarify catalog reads

* Hold ACP option replies in live-write cancellation regressions

* Remove retired Pi startup revision and match saved-pick behavior

* Check running choices and retained rewind messages after delivery integration

* Apply startup picks and let parked messages release operations [skip ci]

* Remove unused Codex opening report field [skip ci]

* Keep ACP restoration facts local to the provider [skip ci]

* Apply Pi startup picks after readiness and remove unused open facts [skip ci]

* Cover goal changes during the held Codex message wait [skip ci]

* Save a waiting startup pick after Stop ends its opening [skip ci]

* Save choices when startup ends or a replacement is still opening

* Check saved choices separately from Claude startup reports

* Release scripted Codex startup before awaiting queued picks

* Check the saved picker selection in per-message choice fixtures

* Exercise current choices and eager client capabilities in runtime fixtures

* Reuse runtime fixture reads and type the fake connection directly

* Align startup fixtures and preserve observed Claude exits

* Honor queued send authorization in Claude runtime fixtures

* Open Codex protocol sessions before Stop fixture dispatch

* Wait for the opened Codex child before runtime notifications

* Keep repeated permission picks usable and align Codex fixtures

* Validate pending picks against the live option state

* Order startup picks before later messages and retain flow regressions

* Keep reviewed lifecycle contracts while pruning duplicate tests

* Save picks after a proven exit without requiring a child

* Type the startup reply fixture against the Claude control contract

* Keep Codex startup flow contracts and remove overlapping adapter tests

* Restore the committed dependency lockfile after test tooling

* Keep startup recovery flows and remove duplicate Pi handshake cases

* Cover both Pi startup choices in the delivery flow

* Match the Pi prompt entry recorded by the scripted wire

* Honor the before-input callback in the startup reply fixture

* Keep message choice flows and remove overlapping selector tests

* Keep startup option menus independent and show the saved choice

* Exercise startup picks across real menus and the runtime

* Match startup picker fixture to saved message choices

* Keep saved effort visible when picking a model during startup

* Include the running-turn scope in repeated Stop fixture

* Use the current attach contract in import fixtures

* Keep the Codex journal translator within its line limit

* Match ACP startup fixtures to saved picks and late Stop behavior

* Check startup effort after saved picker choices are acknowledged

* Complete Stop target types and keep the effort regression concise

* Use requested picker values in the effort regression acknowledgement

* Require owned provider spawn and one host-awaited open (from #28055)

* Restore the live checks the startup cleanup's deleted tests pinned

One assertion each, in the surviving suites: a stale-generation exit is
ignored, a failed create lets its sink go, a readback before open is saved,
an idle stop that fails is retried, a no-pick Pi start teaches its default,
an older client's pre-spawn failure and packed answer, an undrivable chat
stays readable, the resting slash menu stays on its own host, the main agent
reads apart from subagents, restore publishes status, naming runs off the
host's status feed, and a throwing logger never reaches the operation.

Also corrects the reliability gate note that claimed the send-idempotency
suite replaced the refusal-retry tests.

* Read a rewind in doubt outside the chat's serialize and apply it in the commit

The started settle and the attach's ready-child path read the provider's
history first, then apply what it proves in a short commit that re-checks
the rewind it read.

* Accept messages while the agent starts: run the start outside the chat's serialize

The chat's serialize was both where a send was admitted and where its agent
started, so every slow step of a start (closing-child join, lease
reconcile, recovery probes, resolvers, history read, spawn, sink drain,
provider dispatch) held every later send.

A start now registers in one short step before its first wait: its abort
controller (which Stop, close and quit already reach outside the lock),
its startup clock (the 10-minute ceiling now counts from here) and quit's
drain. Every wait after runs outside the lock, raced with that signal, and
every write is a commit that first re-checks the start is still the
session's. A start that loses the check is late: its spawn is closed
through the post-acquisition failure path and nothing is handed to it.

- The delivery loop's prepare returns a start handle; the loop awaits the
  start outside, then re-prepares. Handover writes the pending row and
  begins the dispatch abort handle in one commit, calls the provider
  outside after re-checking the child, and resolves in a second commit.
- Ready operations claim a start instead of running one in their step.
- A client attach runs the same steps under one serialize, as before.
- An exit reported between spawn and publish is held for the publish
  commit, which ends the child it indexes.
- An unproven close still fails only what was queued when its start was
  asked for.

* Test that a send is accepted at every wait an agent start makes

One whole-flow test holds the start at each wait in turn (closing-child
join, reconcile probe, recovery re-probe, resolver, spawn, sink drain,
open, started recovery, pending row, provider call): a second send is
accepted at once, a Stop is recorded without waiting and nothing reaches
the provider, and what was sent goes to the published child once, in
order. Also quit before spawn, concurrent sends, a lease left recovering,
and an idle sweep racing a send.

* Bound each startup step on its own and drop the startup silence rule

A start no longer ends after 60 s without output: output is not protocol
progress. Each protocol step now has its own bound where its handshake
lives, and one 10-minute ceiling from registration stays as the outer bound.

- Codex: initialize 15 s and thread start/resume on the connection's 30 s
  default again, instead of the startup ceiling.
- ACP: initialize 60 s, authenticate 120 s, session new/load/resume/fork
  300 s; turns stay unbounded and option writes keep their caller's bound.
- Claude: the initialize answer has a 60 s bound; past it the open fails
  with the ordinary startup-failure words and the host ends the child.
- The onOutput plumbing that only fed the silence rule is removed.

* Count Pi ready once it reports its state

Pi's start no longer waits for its model listing and slash commands.
The open returns after get_state and the unknown-provider check; the
listing then arrives as an options report (with the catalog listing the
start resolved) and the commands as a later publish. An empty model list
no longer fails the start as signed out: a signed-out Pi runs the unknown
provider, which still fails it, and a prompt it cannot run is refused
with the sign-in words by the existing prompt path.

get_state keeps the connection's 30 s request bound instead of the
startup ceiling.

* Test that model and effort picked while a chat is created ride its first message

A slow host's create reply arrives after the user picked a model and effort. The picker's view
layers the launch's held picks over its seed and the host's saved selection, and every send
carries what the picker shows, so the first message carries the picks whether the host's status
names its selection or not, and while the last pick is still on its way to the host.

* Start a phone's new empty chat on its first message

The phone's new chat is an agent.launch with no prompt, which always created the chat with its
agent started, while desktop chats start on the first message. The phone now advertises the
create-without-start capability, and a launch with no prompt from a caller advertising it creates
the chat at rest, keyed in the ledger by its create intent as a client's create is. Launches with
a prompt, the CLI (which advertises nothing), orchestration workers and ensure still start at once;
an older host ignores the capability and stays eager.

* Take main's lockfile; the branch changes no dependencies

* Restore main's exact text in files the merge only reformatted

The pre-commit formatter rewrote 37 of main's files staged by the merge, though this branch never changed them, pushing two past the line limit. Committed without the hook because the hook would reformat main's text again; CI lints the result.

* Fit the startup tests to the owned-spawn, host-awaited-open contract

Test doubles gain the mandatory open and an acquisition generation, the
removed started event gives way to the rig's proveStart, and two literal
types are tightened for CI's typecheck. A Stop during a held start writes
no host note on this branch: the taken-back send draws its own
stopped-before-start row, which the startup-attempt test now asserts.

* Fit the remaining CI checks to the startup limits and current releases

- Run agent-launch-at-rest.test.ts in the SQLite Node runtime project.
- Pin the send-options cross-version case to v1.4.228, the first published release with agent-session.send-options.v1; v1.4.226 was never pushed.
- Load the baseline's isStructuredTab from src/shared (moved in v1.4.228), falling back to the renderer path for older baselines.
- End the never-proven start with a short startup ceiling: the silence limit it relied on is gone, and the ceiling counts from registration.

* Read the option stamp from the chat's one host-execution selection

* fix: preserve startup authority and accepted agent choices

* Retain dispatch authority and reconcile accepted options

* fix(native-chat): preserve accepted choices and cancellation authority

* Restore established client selection flow

* Preserve provider output observation and typed startup fixtures

* fix: preserve initial Claude launch choices and effort readback

* test: complete Claude catalog session fixture

* Preserve incoming main files after merge formatting

* Preserve compact handover through startup merge

* Type instant startup fixtures before their open is supplied

* Keep browser sign-in under its existing expiry owner

* Finish the main merge: retired-model display, start rewind cut, new tests

A model the Claude CLI retired is reported again, so the picker shows what the child runs and the
next send moves the chat off it. The start reads a rewind in doubt against the handle it answered
with, the record its commit then applies to. Main's tests follow the start's open: the admission
fixture holds a real turn, and a sign-in close during setup fails the open.

* Type the start's rewind record as the store returns it

* Type the Sleep test's held open as the provider open it replaces

* Sleep counts a chat stopped once the start it aborted leaves it at rest

A start runs outside the chat's lane, so Sleep's step can run before the start it aborted has
settled; it then re-reads the chat once that start is done, instead of reporting it unverified.

* Keep Sleep's start-abort bookkeeping beside the rest of Sleep's stop
2026-10-19 10:53:46 -07:00
Brennan Benson 9a662c3980 Native chat: rewind works at any chat length (#30394)
* fix(native-chat): rewind works at any chat length

Rewind copied the kept part of the chat into its saved record and, for
Codex, re-read and re-translated the whole provider history before and
after the revert. Both were bounded (10,000 rows / 2 MB host, 1,024
entries / 2 MB Codex), so long chats were refused with "too long to go
back in".

The record now names the cut; the kept rows are derived from the
journal, which keeps the old epoch until the rewind completes. Codex
reads only the turns it undoes and trusts thread/revert; recovery
proves an applied revert from the newest turn alone and never
truncates otherwise.

* test(codex): type the rewind fixture's page params instead of asserting them

* fix(native-chat): rewind recovery reads the provider handle only from a record that exists

* test: run the rewind cut test in the SQLite runtime project

* fix(native-chat): omit rejected submissions from rewound history
2026-10-18 21:35:31 -07:00
NeilandClaude 139bac88b6 refactor(ssh): move the ssh-connection and ssh-target cluster into ssh/connection/ and ssh/targets/ (#30479)
* refactor(ssh): move the ssh-connection and ssh-target cluster into ssh/connection/ and ssh/targets/

Pure git mv plus import and path-literal rewrites. ssh-connection-utils stays put; the next PR splits it.

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

* test(e2e): keep the release-checkout import at the v1.4.218 path

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 19:23:21 -07:00
Neil 1938bd055a refactor(ipc): group SSH and runtime-environment IPC handlers into ipc/ssh/ and ipc/runtime-environments/ (#30362)
git mv plus import, vi.mock and path-literal rewrites; e2e route regexes and the Windows host lane
filter follow the moved handlers.
2026-10-18 08:11:52 -07:00
Neil 506aa03195 refactor(managed-server): move the managed server's lifecycle into src/main/managed-server/lifecycle/ (#30343)
orcad-managed-*, orcad-runtime-*, orcad-stdio-bridge-*, orcad-decommission-* and orcad-rollback-*
leave src/main/ssh. git mv plus import and path-literal rewrites; two e2e route regexes learn the
new folder. orcad-runtime-maintenance stays for now because open #29834 edits it.
2026-10-18 06:01:46 -07:00
Neil 2d1ec53533 refactor(ssh): fence the legacy relay in ssh/legacy-relay/ and drop the relay name from live deploy primitives (#30292)
Moves legacy-relay-bridge/ plus the relay-only census and endpoint files into ssh/legacy-relay/,
so the 2027-01-15 retirement deletes one folder. Live install, exec and framing modules that only
carried the relay name become remote-*. git mv plus import, path-literal, routing and workflow
path-filter rewrites only; no remote file name or path changes.
2026-10-18 03:17:41 -07:00
Brennan Benson b3048a2b9c Wait for agent readiness without blocking native chat (#27925)
* Finish structured agent readiness before delivering held work

* Adapt permission fixtures to startup lifecycle

* Keep model discovery for chats before their first start

* Use one host readiness promise for provider operations

* Await provider opening in the execution host

* Await Claude initialization in host-owned open

* Observe Claude readiness through the host status

* Keep optional readback behind host readiness

* Recheck launch permission after host readiness

* Preserve the awaited child during permission revalidation

* Record failed owned opening before the first message

* Abort a matching provider open when its child exits

* Cover provider exit during an unanswered host open

* Cover explicit failure facts on a host-stopped child

* Keep startup waits cancellable and preserve operation order

* Keep live choices and delivery order while agents start

* Apply startup picks and let parked messages release operations [skip ci]

* Save a waiting startup pick after Stop ends its opening [skip ci]

* Save choices when startup ends or a replacement is still opening

* Check saved choices separately from Claude startup reports

* Align startup fixtures and preserve observed Claude exits

* Order startup picks before later messages and retain flow regressions

* Keep reviewed lifecycle contracts while pruning duplicate tests

* Save picks after a proven exit without requiring a child

* Type the startup reply fixture against the Claude control contract

* Honor the before-input callback in the startup reply fixture

* Keep startup option menus independent and show the saved choice

* Exercise startup picks across real menus and the runtime

* Keep saved effort visible when picking a model during startup

* Include the running-turn scope in repeated Stop fixture

* Use the current attach contract in import fixtures

* Align carried-message fixture with startup event contract

* Keep launch-only permission picks usable while prior writes wait

* ci: rerun on main with the locale fix

* Preserve goal retries and chat launch selections

* Give launch regression its request identity

* test(native-chat): type the provisional-launch hook's session prop
2026-10-16 12:44:35 -07:00
Jinwoo Hong 18df97a7bc Refuse a chat create whose close came first (dark, build switch off by default) (#29659)
* Refuse a chat create whose close came first, behind the layout-owner switch

With ORCA_LAYOUT_OWNER=main, main remembers a close that reaches it before
the chat's create and refuses that create, or closes the chat as it lands.
The window then tells this machine's host about a cancel instead of
keeping a cancel tombstone; a host it cannot tell still gets the
tombstone. Paired hosts keep the tombstones, whose fence module moves to
lib/launch-cancel-tombstones-compat/.

* Close a chat whose create a cancel beat, on any create outcome

The local cancel now retires the chat on main (close and host-tab close),
so a create that landed first leaves no tab behind; a tab_not_found answer
means the create had not landed. Main closes the chat for any create
answer once a close was noted, including a refusal or a throw past commit,
and publishes no tab for it. The tombstone fallback notifies listeners.

* Keep the switched-off create path byte-identical

* Log, not throw, a failed close of a chat closed before create

* Run the close-before-create test in the Node SQLite project
2026-10-16 05:41:33 -07:00
Neil 1855ba49ba ci: reuse prepared natives in Windows serve-switch builds (#29595)
* ci: reuse prepared natives in Windows serve-switch builds

* ci: route legacy relay SQLite fixtures through Node
2026-10-16 03:32:16 -07:00
NeilandClaude c188c12543 fix(orchestration): remote worker liveness reports exited when the host proves the agent gone (#29276)
The execution host upgrades a remote worker's fleet/show verdict to exited
only on its own proof: the hook store retains a proven agent exit (fenced to
the attachment, cleared by a replacement agent), and after a reboot the
process table proves the PTY gone. Lost contact stays unverifiable.

Fixes #24353

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 23:27:16 -07:00
Brennan Benson 7a78dbd3c7 Native chat: one settlement transaction for everything an ended run left (4/5, rebuild of #26867) (#28111)
* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* feat(native-chat): one current-work projection for every host reader; record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.

* feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly

Desktop and mobile prefer the host's working and actionablePromptIds fields
when present (merged across batches like latestTurn) and fall back to today's
row derivation against an older host. A prompt an ended agent raised stops
looking active; answering it is refused before any provider sees it, with
"<Agent> has stopped, so your answer was not sent. Send a message to continue."
Cancelling it only dismisses it in the journal: it no longer triggers the
chat's Stop, pauses the queue or withdraws queued cards.

* feat(native-chat): one settlement transaction for everything an ended run left

An exit now settles, in one lifecycle batch planned at its own turn in the
write queue, everything its run left (turn, calls, prompts, reasoning,
unanswered sends) plus leftovers of earlier ended runs, and only the subagent
roster entries and background tasks those runs observed, by each entry's own
provenance. Main's stale settle moves to stale-state-settlement.ts with fence
scoping; dead-generation-settlement.ts becomes a planner;
leftover-settlement.ts combines them.

Resume eligibility widens in one case: this host saw the root exit at the
lease's fence and the release write failed; the acquisition replaces the
owner only once a probe proves it gone.

* refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.

* test(native-chat): fewer client tests: one flow per client, one fallback each

Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows.

* test(native-chat): fewer settlement tests: keep the exit-delivery flows and the per-entry scoping flow

Drops the no-added-delay timing check, the frame-once check, two stamping unit cases and the Codex combined case (the roster flow test covers its outcome).

* test(native-chat): drop the resume-eligibility predicate mirror; move the unstamped-entry case to Part 1

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers

* fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt

The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation
receipt and without the dismissal's failure handling, so a receipt write failing after it would
answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which
carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a
null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's
adapter refuses a fence with no live session outright).

* Native chat: a resumed subagent or Claude background task belongs to the run executing it

A subagent row that run 1 created and run 2 resumed kept run 1's creation
fence, so settling run 1's death marked run 2's live subagent unverifiable.
Claude background tasks reuse their rows the same way (a matching parent tool
use, or a parentless row).

Rows of reusable child execution now also name their execution owner: the
fence of the child process whose report the write copies, captured when that
report arrives, never the lease that is current when a queued write flushes.
- Subagent rows: the host captures the live child's fence when its child-work
  report changes the records, and rewrites a running subagent another child now
  reports even when its text is unchanged.
- Claude background-task rows: written through that child's own event sink,
  which stamps its bound fence.
- Creation provenance (`ownerFence`) is unchanged. Writes that do not state an
  owner (settlement, Pi's start usage, ACP session/load replay, ACP tasks) keep
  it, and a row without one falls back to its creation fence.

`journal.itemFence()` now answers whose execution an item is
(`executionOwner ?? ownerFence`), so every settlement and liveness read, inside
its write transaction, follows the run that resumed the work.

Also drops the event sink's compatibility alias for the identity resolver (the
Codex sink names the resolver itself), keeping the sink under its line limit.

* Native chat: a subagent row is written through the child whose report it copies

Review of c0add67cca: the subagent row writer read its execution owner from
the host's installed child when a report landed. A child that is still being
acquired reports a resumed subagent before the host installs it, so its row
was written at once under the old owner, then the attach's settlement of what
the previous child left marked it unverifiable, and nothing rewrote it until
the next report.

Each adapter (Claude, Codex, ACP) now hands its child-work report to the host
with that child's own event sink, the write path its rows already take. The
host passes it through the status feed to the subagent row writer, which writes
the rows that report changes through that sink with `executes`. They land in
the child's own order, after the attach's settlement, at the fence the sink is
bound to, and that fence is the row's execution owner. A running subagent a
child has not written yet is written once even when its text is unchanged.
Changes the host makes itself (a Stop's acknowledgement, an end) still write
directly and keep the row's owner. The shared child-work evidence type is
unchanged: the sink travels as a separate argument.

Test: a Claude helper the next child reports again while it is still starting
stays working after that start's settlement, through the shipping adapter and
host. It fails on c0add67cca (the row lands `unverifiable`).

* Native chat: the subagent row writer always awaits a promise

CI's type-aware lint flagged the row writer handing `Promise.all` a value
that could be undefined when a sink has no `written()` barrier. A sink without
one now reads as landed.

* Native chat: keep refused subagent writes, and carry both owners through a rewind

Review of b161aa38b1 / 129f36d49b:
- `admitChildWork`'s producer is optional again, so the host's own
  turn-started report compiles.
- A subagent row written through the reporting child's sink uses the sink's
  checked admission. A write it refuses under backpressure is queued again
  once what the sink holds has landed; a refusal or a failed write frees that
  child's claim on the row, so a later report retries it.
- A rewind carried `itemFence()` (now whose execution an item is) into the
  rebuilt row's creation `ownerFence`, and dropped `executionOwner`. Retained
  rows now carry both as stored (`journal.itemProvenance`), the rewind record
  keeps `executionOwner` beside `ownerFence`, and both rebuild paths write it
  back: epoch replacement and the context rewind, which builds its rows from
  the same replacement items.

Test: the two-fence test now rebuilds the epoch from the resumed rows and
settles the first run's death again; the resumed rows stay live. It fails if
the epoch replacement drops `executionOwner`.

* test: run the resumed-child owner test in the Node SQLite project

It opens a real journal database, so it belongs on the list the SQLite runtime
boundary test checks.
2026-10-15 19:35:36 -07:00
865cdaa706 fix(native-chat): acting on one chat no longer waits on checks of every other chat (#26555)
* fix(native-chat): acting on one chat no longer waits on checks of every other chat

Starting, resuming or switching a chat's agent first ran restart
reconciliation, which probed every unreconciled session and committed them
together, so one slow or failing probe for another chat delayed or failed
this chat's action. Acquisition now reconciles only the addressed session;
the explicit startup pass still covers all of them, and an owner that isn't
proven still can't be granted a writer.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native-chat): settle each restored chat on its own; register new sqlite tests

- Startup restore no longer lets one chat's failed lease check skip recovery for the others.
- Run the two new SQLite-backed tests in the Node runtime project.
- Startup reconcile runs the whole-host pass without a placeholder session id.
- Test names describe an injected failing/stalling owner check, not an SSH host.

* fix(native-chat): restore keeps one whole-host lease check per startup

The restore opens with the whole-host pass again and stops per-chat bookkeeping after the
first failure, as on main: on a failing store it makes one lease-check write, not one per
chat. User actions stay targeted to their own chat.

---------

Co-authored-by: OpenAI Codex gpt-6.1-sol <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:28:43 -07:00
699709c0ba fix(ssh): never leave an SSH host unconnectable after a refused move (phone users, #29083, Move anyway) (#29220)
* fix(ssh): never leave an SSH host unconnectable after a refused move to its managed server

* fix(ssh): keep unsaved drafts out of what Move anyway leaves behind

* test: run the stuck-host profile test in the Node runtime project

* fix(ssh): spend a "Move anyway" consent when the conversion throws

A throw left managedServerLeaveBehind set, so a later connect left saved
state behind without asking again. A resumed fence replays its journal,
never the export, so clearing is safe.

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

* Revert "fix(ssh): spend a "Move anyway" consent when the conversion throws"

This reverts commit 4f63d777d9.

* docs(ssh): note why a Move anyway consent survives a conversion throw

* fix(ssh): Move anyway needs every terminal proven exited, and skips foreign tabs fully

- A bridged terminal proof (relay census live or unverifiable) now refuses a
  confirmed "Move anyway" before any fence or journal, with a reason that says
  to close the terminals; the host stays on its relay and connectable. The
  offer itself is withheld while terminals aren't proven exited.
- A foreign tab left behind is also removed from the legacy tabsByWorktree
  rows, its layout, active-tab pointer, relay session id, scrollback, and
  pane-keyed resume records, so it can't reappear on the managed server.

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

* fix(ssh): Move anyway drops left-behind files' tabs, and is offered only when it would work

- An editor tab whose host-bound file is left behind is removed with the
  file, so the managed server never opens it as a blank pane.
- The offer is previewed against the leave-behind export first: when what
  refuses is a desktop-only draft the export can't leave, no "Move anyway"
  is shown and the save-or-discard reason stays.

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

* fix(ssh): drop surface and closed-tab tombstones of foreign tabs Move anyway skips

* fix(ssh): match legacy pane keys when Move anyway drops a foreign tab's records

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 18:26:18 -07:00
Neil a3373af635 ci: skip unused platform runs and reduce runtime test setup (#29144)
* test: reuse automation fixtures and narrow long-poll setup

* test: load terminal RPC methods only in consuming scenarios

* ci: skip packaged Orcad lanes for unrelated unit test edits

* style: format the WSL Orcad workflow
2026-10-15 05:06:12 -07:00
Brennan Benson 635d3853f1 fix(agent.launch): avoid blank shells when launching new workspaces (#28451)
* fix: avoid blank shells beside new workspace chats

* Carry promised agent chat through worktree creation

* Use live agent launch inventory for new workspace surfaces

* Type live agent launch owner from runtime contract

* Assert one recovery shell after failed agent launch

* Keep worktree activation adapters out of creation flow

* Restore ordinary workspace setup handoff after launch correction

* Cover the phone's agent.launch workspace create in the first-surface flow test
2026-10-15 01:21:24 -07:00
Brennan Benson d9de1fad1d Native chat: desktop and mobile read the host's working answer; plain refusal for a stopped agent's prompt (3/5, rebuild of #26867) (#28104)
* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* feat(native-chat): one current-work projection for every host reader; record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.

* feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly

Desktop and mobile prefer the host's working and actionablePromptIds fields
when present (merged across batches like latestTurn) and fall back to today's
row derivation against an older host. A prompt an ended agent raised stops
looking active; answering it is refused before any provider sees it, with
"<Agent> has stopped, so your answer was not sent. Send a message to continue."
Cancelling it only dismisses it in the journal: it no longer triggers the
chat's Stop, pauses the queue or withdraws queued cards.

* refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.

* test(native-chat): fewer client tests: one flow per client, one fallback each

Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows.

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers

* fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt

The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation
receipt and without the dismissal's failure handling, so a receipt write failing after it would
answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which
carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a
null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's
adapter refuses a fence with no live session outright).
2026-10-14 23:41:52 -07:00
Brennan Benson 3bfb1149da Native chat: one current-work projection for every host reader, death recorded first (2/5, rebuild of #26867) (#28103)
* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* feat(native-chat): one current-work projection for every host reader; record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.

* refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers
2026-10-14 20:19:23 -07:00
6837a4c180 fix(ssh): a relay-era desktop can join the server another desktop on the same login runs (#28886)
* fix(ssh): a relay-era desktop can join the server another desktop on the same login runs

A desktop that still had relay-era projects on a host whose managed server
another Orca version ran could never connect: its migration skipped the
running-version adoption, so it deferred on a terminal count only a paired
server can give. Once the versions matched it failed instead because the
server already held the same repository path, added by the other desktop.

The migration now pairs with the running server like a fresh desktop does,
before any upload. Projects the server already holds at the same host path
are linked to the server's project instead of imported twice: the journal
is re-cut without them, under the same migration id, only after the server
proves it holds nothing of the earlier manifest. A conflict the server will
keep refusing now says what conflicts and how to get past it.

* fix(ssh): never link a project a bridged relay terminal still runs in

The bridge reattaches a relay pane only into its own tab, which a linked
project never sends to the server, so linking would leave that terminal
running out of sight. Refuse the link instead, naming the project and how
to end the terminals.

* test(ssh): run the link test on Node's SQLite runtime and read the host's activation record

* test(ssh): establish desktop B's profile before seeding its relay-era state

* test(ssh): connect desktop B's host explicitly in the shared-login spec

* fix(ssh): say what a linked project left behind, and never blame it for an unowned relay terminal

A project this desktop moved that the host's server already held is linked to the
server's copy, so its tabs and automations from this desktop stay behind. The
connect now carries those linked projects and the SSH host card says so, with the
automation count, instead of dropping them silently.

A bridged relay terminal of unknown or removed project still blocks a link, but its
guidance no longer names the linked project as where it runs. Both terminal
messages now offer removing the project from this desktop as the way out.

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

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:48:25 -07:00
Brennan Benson e2fa611916 feat(native-chat): chat scratch folders expire after 30 days unused (#28394)
* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* feat(native-chat): chat scratch folders expire after 30 days unused

A chat's scratch folder was removed only when its record was gone or its
local workspace was proven deleted, so the folder of a long-idle chat
lived forever.

The sweep now expires a chat's folder once its newest journal row and
the newest entry anywhere in the folder (complete, no-follow scan) are
older than 30 days, unless the host holds a running agent, a delivery or
an open dispatch for the chat. The final recheck and a rename to
<hash>.expired run in the chat's session lane and then a per-folder lock
that Orca scratch saves (desktop IPC and host RPC) also take; the delete
runs outside both. A save that settles after the age scan marks that
expiry stale. Leftover .expired folders and the pre-rename
native-chat-visuals root are removed.

The workspace-removed rule and its catalog verdict are deleted.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* test(files): drop the loop case that duplicated the dangling-link refusal

* refactor(native-chat): trim the scratch expiry and test the real live-work veto

- Remove the other-profile catalog options (running profile id, never-written
  profiles) that only the deleted workspace verdict used; history cleanup's
  reader is unchanged.
- The sweep keeps its original remove-and-warn block; an unused chat's
  folder is parked by one function. Drop the unused `now` option and the
  candidate identity check.
- The live-agent case runs against the host's real runWhileExecutionIdle.

* style(native-chat): keep the session host within its line limit after the main sync

* fix(native-chat): every scratch save and every overlapping sweep share the folder lock

Binary (base64) runtime saves now take the scratch folder lock like text saves, and each folder
keeps a set of expiry candidates so one sweep finishing no longer hides a save from another.
2026-10-14 18:22:39 -07:00
Brennan Benson 850e71e672 Resume in New CLI for OpenCode and OMP chats (#28075)
* feat(chat): Resume in New CLI for OpenCode and OMP chats

OpenCode's and OMP's CLIs have no copy flag, but their chat protocol copies a
conversation (ACP session/fork). For a fork of one of their chats, the chat's
host now has a short-lived agent, launched exactly as the chat's own, copy the
conversation; the terminal then opens the copy with a plain resume. The chat
keeps its own conversation, so the CLI is never a second writer on it.

The copy travels on the existing aiVault.prepareSessionResume fork request as
an optional forkedSessionId; a host that predates it answers without one, and
the client asks for an Orca update instead of resuming the original.

* fix(chat): open an OpenCode 2.x chat's CLI copy with opencode, not opencode2

The copy kept the row's agent, so Resume in New CLI ran `opencode2 --standalone --session <copy>`,
which OpenCode 2.x doesn't install. The chat's host now answers the copy with the agent whose CLI
opens it: the chat's own.

* fix(chat): type the copied conversation's agent as a history agent

* fix: preserve independent chat copies through CLI launch

* test: keep only the tests that pin user-visible behavior

* fix(chat): validate OpenCode copies on their execution host

* test(chat): expect copied OMP forks after main integration
2026-10-14 16:06:50 -07:00
Neil cc03142b9c feat(wsl-mover): background writers step aside for a project a WSL move is taking (D1b-5c-3) (#28835)
* feat(wsl-mover): background writers back off a project a WSL move is taking (D1b-5c-3)

* fix(wsl-mover): re-check before renames, quiet owned automations, stable folder ids (D1b-5c-3)

* test(wsl-mover): seed journals one phase at a time and advance existing moves (D1b-5c-3)

* fix(wsl-mover): a branch rename that already ran settles even when the move skips the folder (D1b-5c-3)
2026-10-14 14:13:51 -07:00
Neil 8f83f6b076 feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) (#28820)
* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d)

* fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d)

* test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)
2026-10-14 13:07:50 -07:00
Neil b0308f24ef feat(wsl-mover): refuse edits to a project a WSL move is taking (D1b-5c-2) (#28761)
* feat(wsl-mover): refuse edits to a project a WSL move is taking, and drop its background bookkeeping quietly (D1b-5c-2)

* fix(wsl-mover): hide only local rows of a moved project; the host's own row keeps its id (D1b-5c-2)

* fix(wsl-mover): refuse a user's unread toggle, fence branch deletes, host setups and issue commands; settings unfreeze once the host owns a repo (D1b-5c-2)

* test(wsl-mover): the fence e2e refuses edits to moving projects and drops their bookkeeping quietly (D1b-5c-2)

* refactor(wsl-mover): name the update type the fence inspects (D1b-5c-2)

* test(wsl-mover): run the fence tests that open a real store in the Node runtime (D1b-5c-2)

* fix(wsl-mover): a host setup's own repo stays frozen once the host owns it; e2e pins the quiet drop (D1b-5c-2)
2026-10-14 12:13:28 -07:00
Brennan Benson 04b4e962a7 fix: a Claude model that's gone switches to the list's default when you send (#27826)
* fix: start a new Claude chat on the listed default when its remembered model is gone

The remembered new-chat model was launched as-is even after Claude stopped
offering it, so every new chat failed its first message. The host create and
the desktop new-chat picker now derive the same replacement from the host's
saved model list: the listed default, effort moved to the nearest level it
offers. Explicit models (--model, held picks) run as given; with no saved list
the remembered model is kept.

* fix: read the host's model list only for a Claude create that needs it

The check read host.deps before ruling out other agents and explicit models,
so creates whose host carries no deps (and every non-Claude create) paid for
a lookup they never use.

* fix: picker derives the fallback only where the host would

A held model runs as picked on the host, so the picker no longer derives an
effort beside it. A cached list whose default marker was dropped for this
workspace keeps the placeholder instead of naming its first row.

* fix: the new-chat picker picks the replacement for a gone Claude model

The host-side check read the saved list separately from the picker, so the
two disagreed and a cold start never fired it. The desktop new-chat picker
now picks the replacement it shows, once, through the existing pick path,
so the first send runs it and the remembered selection heals. The host is
back to main.

* fix: decide a gone Claude model's replacement at send, from the list in hand

The open-time auto-pick raced the chat's create and the list's arrival, so
the first send could still launch the gone model. Now, like the common
pattern, the picker shows the replacement for a model the host's loaded
list lacks, and the send step applies it through the existing pick call,
awaited, right before the message goes out. A failed pick never holds the
send back.

* fix: one verified list decides a gone model's replacement

The picker, the send step and the option setter now all read the list the
picker shows, less the row a session adds for its own unlisted model, so
what is shown is what applies and an explicit pick from that list stays.
A listing that is still running, failed, or absent keeps the model. The
send step re-derives from the chat's state when it runs, so a second send
behind one that already moved the model applies nothing.

* fix: only rows a finished host listing names can replace a gone model

A single verified flag outlived the catalog it described: live options built
from the built-in fallback list, or a malformed or empty host answer, still
authorized a replacement the host would refuse. The state now keeps the ids
a finished, well-formed host listing names, and a replacement must be both
shown in the picker and named there.

* fix(native-chat): the send carries the composer's Claude model and effort

Every composer send (desktop and phone) carries the model and effort the
picker shows, as optional agentSession.send options, only to a host that
advertises agent-session.send-options.v1. The host writes a carried value
that differs from the chat's own through the existing pick path before
accepting the message, so the turn starts on it and the chat records it.
A queued card keeps its options and applies them when it goes out; a
launch's create call carries the replacement for its first message.

Replaces the separate awaited pick before each send.

* chore: drop an unrelated lockfile change

* refactor(native-chat): one helper names a send's control fields for client and host

* ci: rerun against current main

* fix(native-chat): a message runs on the model and effort it was sent with

The carried selection is now stored with the message (its submission row,
and a queued card's row) and applied through the shared option writer as
the delivery loop hands the message over, or before the start it needs.
A pick made after the message was accepted no longer changes the model it
runs on. Direct sends, queue drains and Send now share that one apply
point; the early apply before acceptance and the queue-only apply are gone.

The picker and the send read one effective selection: the replacement for
a model the host's finished listing lacks shows only for a host that takes
a send's selection, the phone validates picks against what it shows, a
send carries the default the composer shows, a resumed launch's first
message gets the same resolution, and only the listing proves a row (no
label heuristic).

* docs(native-chat): a submission's options are the host's alone

* fix(native-chat): an opening message keeps its model; a pending pick resolves like the picker

The opening message a create sends now stores the model and effort the chat
was created with, so a pick made before its handover no longer changes what
it runs on, and a card kept after a restart keeps them.

A pick on its way to the host resolves through the same option view the
picker shows before a send carries it, so a gone model it names is replaced
too; the send selection reads only the shown snapshot.

The option writer builds its own record-and-publish context, so the message
path no longer duplicates it, and the client and host share one function for
a send's fingerprint fields.

* ci: rerun against current main
2026-10-13 21:21:47 -07:00
Brennan Benson a622bb0f4b fix(native-chat): phone Stop reaches a message the agent hasn't answered; unreadable task list reports unconfirmed (#28243)
* fix(native-chat): phone Stop reaches unanswered sends; unreadable task list is unconfirmed

The phone named the running turn on every Stop, so a Stop pressed before the
agent echoed the message said "Stop not sent" and the agent ran on. Against a
host advertising agent-session.conversation-stop.v1 the phone now sends the
Stop that names no turn, as the desktop already does; the host stops whatever
the chat has in flight. A card's Cancel still names its turn, and older hosts
keep the turn-named Stop.

A background-task Stop whose host could not read the session's task list read
the failure as "no tasks" and answered cancelled:false. It now refuses as
outcome unknown, the refusal a timed-out background stop already returns.

Adds host tests pinning the Stop-before-echo races on the unnamed Stop: Codex
turns admitted or started before the echo, a later send just before the Stop,
a retried Stop, and a Stop after a failed one. All pass on main unchanged.

* fix(mobile): show Stop before the turn opens; a card's Cancel keeps its turn

The phone offered Stop only once a turn existed, so the Stop that names no turn
was unreachable while a send waited for the agent's echo. Against a host that
advertises conversation stop, a working chat now offers Stop.

A card's Cancel chose the untargeted form from the prompt after the
prompt-cancel capability filtered it out, so against a host with conversation
stop but no prompt cancel it sent a Stop naming nothing. It now always names
the card's turn.

The race test for an admitted turn that cannot take an interrupt accepts
success only once the host has let go of the agent, and otherwise requires the
unconfirmed refusal.

* ci: rerun against current main

* ci: rerun against current main
2026-10-13 19:50:06 -07:00
Kelvin AmoabaandBrennan Benson 885d8a0046 feat(native-chat): show context usage in Codex chats (#28084)
* feat(native-chat): show context usage in Codex chats

Codex turn-row writes become journal revisions so the reading survives a turn end.

* test(codex): run the context-usage journal suite under Node

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-10-13 16:47:07 -07:00
Brennan Benson 4d65146f2d fix(native-chat): Stop is saved before it acts, and a late or repeated Stop does nothing (#27076)
* feat(native-chat): add a chat-owned command receipt store

Adds the agent_session_command_receipts table and an insert-if-absent
receipt write that runs inside a command's own journal transaction. A
receipt records the command's identity, canonical hash, status and a
pointer to the row holding its result; it dies with its chat record.
Nothing uses it yet: the existing operation ledger stays authoritative.

* fix(native-chat): key command receipts like today's ledger ids

Rows are keyed by caller and operation id; a send looks across callers and
other methods look up only their caller's row, matching the existing ledger.
Bad rows and newer table shapes read as unreadable instead of throwing.

* fix(native-chat): order receipt lookups by insertion and key by operation id

Global lookups now take rows in insertion order, so a read and an insert
agree on which receipt answers an id regardless of the host clock. The
primary key leads with the operation id, so its own index serves both
lookups.

* fix(native-chat): accept sends through their own receipt

Send (direct, queued, steer) and /compact no longer commit a separate
ledger reservation before the message is saved. Their command receipt is
inserted-if-absent in the same transaction as the submission or queued
draft; a repeated id replays from it, a changed request conflicts.
Operation ids are checked for syntax only, so age, clock skew, pruning
and the reservation's own storage can no longer refuse a send.

The ledger success row they already co-committed stays as a temporary
insert-if-absent write so families not yet switched keep seeing these
ids; it is never read on this path.

* fix(native-chat): answer a send's receipt one way, share /compact's id space with Send, wake delivery once

- After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone.
- /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write.
- The receipt route calls its runner directly; the runner wakes delivery when a new message row commits.
- The compatibility ledger row expires from host time; each temporary site is labelled.
- Receipt read errors and post-commit failures are logged.

* test(native-chat): expect receipt answers where tests assumed the send ledger

Restart-continuation tests now model the case Send cannot vouch for (saved but
not loaded into memory) and keep it unconfirmed; a later throw after the message
is in the chat answers continued, as a resend would. Drop /compact's unreachable
legacy replay branch and fix stale comments.

* test: run the queued tests that open the journal database in the Node runtime project

* fix(native-chat): queued-message actions and prompt answers accept through their own receipt

Send Now, Delete, Resume and prompt answers move onto the command-receipt route
Send uses: the receipt is inserted with the command's own write. A no-op answers
only once its receipt commits (otherwise outcome unknown), and a prompt answer's
replay reads the exact revision it was accepted at.

* fix(native-chat): replay a queued action from what its receipt recorded

Send Now's acknowledgement points at the submission it handed off; a question
answer's receipt keeps the accepted resolution; a no-op that cannot save its
receipt answers outcome unknown and holds nothing in memory; classified refusals
pass through.

* docs(native-chat): receipt comments say what a replay answers from

* fix(native-chat): Stop saves first, captures its target, and holds queued messages

Stop accepts through its own receipt: one transaction saves the Stop record
(target turn, agent generation, cutoff), returns unsent messages to the queue as
paused cards, and writes the receipt; only then does it interrupt the captured
target. A failed save leaves the agent running and says so. A late Stop is a
recorded no-op, and a retry is answered from the receipt.

* fix(native-chat): Stop round 1: late Stop is a no-op, saved-then-failed refuses, hold only what the Stop did not stop

- A Stop naming a turn that already ended is an accepted no-op.
- A card's Cancel saves its receipt with the dismissal row.
- A Stop saved but not carried out is refused as stopFailed ("Couldn't stop <agent>. Try again.") and its receipt is rewritten as rejected.
- A Stop during a start is saved first, then aborts that start; the abort-before-admission path is removed.
- The message the Stop stops is withdrawn as before; messages behind it are held as paused cards. Non-text messages are still withdrawn (TEMPORARY).
- Cleanups: one receipt commit path, dead fields and helpers removed, the returned-to-queue text names the agent.

* fix(native-chat): Stop round 2: narrow to receipts and save-first; message fate as on main

- Stop's withdrawal of queued sends is main's own rows (rejected, cancelled), written in the same
  transaction as the Stop event and its receipt; a failed save withdraws nothing.
- A Stop arriving during a start aborts that wait on arrival, as on main (admits-now guard), then
  captures its target once in the session lane. The out-of-lane admission is removed.
- A committed receipt is never rewritten: a failure after the save answers from the receipt and
  writes the existing 'Cancellation was not confirmed.' note. 'Couldn't stop <agent>' only when
  the save fails; a card Cancel that can't be saved says the question or approval wasn't cancelled.
- A Stop naming a turn a rewind removed is late (an accepted no-op).
- Removes the held-card settlement and returnedToQueue: what happens to a stopped message stays as
  on main.

* test(native-chat): name the Stop row comparison for what it drops

* fix(native-chat): Stop round 3: a committed Stop always acts; notes only for unconfirmed interrupts

- A Stop whose write throws after its receipt committed still interrupts (never 'Couldn't stop').
- An interrupt that worked answers stopped even when only its note fails to write.
- A strict card interrupt that throws after its receipt committed gets the 'Cancellation was not
  confirmed.' note, through one shared helper with the turn path.
- A target-less Stop stands while the main agent works, so a turn that opened while its save waited
  (a send made before the Stop) is interrupted, as on main.
- A card Cancel that can't be saved says 'Couldn't cancel this question or approval. Try again.'

* fix(native-chat): Stop round 4: name the turn the Stop actually stops

- The Stop event's turn is resolved when its write runs (the named turn, else the live turn), so a
  turn that opened while the save waited is the one marked interrupted and shown stopping.
- The unconfirmed note is keyed by that turn; tests cover it and its own write failing.
- Whether a Stop withdrew anything comes from its capture, so a throw after the commit can't flip it.
- A Stop counts as saved anyway only when its own write committed the receipt.

* fix(native-chat): a new chat's first send gets the Stop-aware operation receipt

Main's create path (from #26332) hands performSend the founding receipt; the
Stop changes widened the turn context's receipt to track its commit. Wrap it
with the existing observedReceipt.

* fix(native-chat): a background task the agent won't stop still says not done

A background-task Stop now saves its receipt before it asks the agent. A
refusal after that save (task still running, or a timeout / lost connection)
was being replaced by the receipt's replay, so the person never heard the Stop
failed. That refusal is now the first answer, as on main; the receipt stays
saved and a retry of the same id is answered from it without asking again.

* test(acp): a Grok Stop naming an ended turn leaves the unechoed follow-up alone

The late-Stop rule makes a Stop naming a turn that has ended an accepted no-op
for every agent. The Claude case was rewritten with it; this is Grok's.

* test(native-chat): Stop tests exercise what they name after the late-Stop rule

- The "leaves the child alone" Stop now reaches the provider (handed over, a
  turn with no row yet); the at-rest refusal case was a duplicate of the late
  no-op test and is removed.
- The adapter comment and two test names say the gap rule covers a named turn
  with no journal row yet.
- The Grok late-Stop test checks no status row and that the follow-up completes.
- Name the two refusals refusalAnswers passes through.

* fix(native-chat): a background-task Stop answers its acceptance and notes what happened

A background-task Stop is saved before it asks the agent. The answer is
that saved acceptance: a stopped task answers cancelled:true as before
(the phone keeps the row "stopping" on it), and anything else answers
cancelled:false. A retry answers from the receipt without asking again.

What the agent then says comes back as a note keyed by the Stop's own id,
written through the existing Stop-note path, and never as a refusal:
- still running: "The background task wasn't stopped." (or "The
  background tasks weren't stopped." for Stop all), the new status-row
  failure kind backgroundTaskNotStopped;
- timed out or lost the agent: "Cancellation was not confirmed."
The note is the thread's, since tasks outlive their turn. A note that
cannot be written is logged and changes no answer.

The background-task body moves out of performCancel into the targeted
cancel, and the refusalAnswers flag and its receipt are deleted.

* test(runtime): the Codex strip's Stop notes a command it could not stop

A background-task Stop now answers its saved acceptance and says what happened in a note: "The background task wasn't stopped." when the command still runs, "Cancellation was not confirmed." when Codex was lost.

* test(native-chat): keep the Stop tests that pin behaviour, cut the rest

Merges the receipt, save-first, failed-after-save and background-task Stop
tests into one flow file; drops unit and per-branch tests already covered
by a flow test.
2026-10-13 15:30:55 -07:00
Brennan Benson c5d7628686 fix(native-chat): chats from before permission modes keep the mode they were running with (#28099)
* fix(native-chat): chats saved before permission modes run the new-chat default

A Claude or Codex chat created before chats had their own permission mode has
none saved, and it ran Ask, so every existing chat of a Yolo user started asking
for approval after the update. A chat with no saved mode now runs Settings ->
Chat -> Permissions, read from the host's settings on each launch, read and
restart check.

* fix(native-chat): an older chat keeps the new-chat mode its first start ran with

A chat saved before chats had permission modes ran the new-chat default on every
start, so it kept following that setting. Its first start now writes the mode it
ran into the chat's saved options, through the same write that already carries the
chat's other options over a start; from then on it is a chat with its own mode.

* fix(native-chat): save older chats' permission mode once at host startup

Replace the save-at-first-start path with one startup pass: before the chat
host is exposed, every Claude and Codex record with no saved permissionMode
gets the host's new-chat default, in one store transaction. Saved values,
including invalid ones, and every other option are left alone; a read-only
store is skipped and a failure is logged and retried next start.

The runtime fallback in launch, acquisition, provider-started and attach is
restored to main's behaviour. A client-supplied attach that creates a new
record now gets the same new-chat seed as every other create path.

* fix(native-chat): fill each older chat from its own agent's arguments

An older Claude or Codex chat ran with that agent's own Arguments before
permission modes existed, so the startup pass now saves full access or ask
per agent instead of the combined new-chat default. A user with Claude on
bypass and Codex on Manual keeps Claude chats on full access.

The attach create path always passes its permission seed; the reservation
already applies options only when it creates the record.

* refactor(native-chat): read each chat's agent arguments directly in the permission backfill

* test(native-chat): give stub runtimes the launch seed the attach path reads
2026-10-13 12:34:27 -07:00
Jinwoo Hong 3a011e48d1 fix(native-chat): load long chats on the phone by keeping every update within its limit (#28203)
* fix(native-chat): fit chat stream pages in one mobile bridge frame

A long native chat failed to open on a phone with "the shell ended this
stream (overflow)". The host sized each history page to ~1.94 MiB (half
the relay cap), but the mobile app ends any stream whose event frame is
over 640 KiB, so the opening snapshot of a long chat never arrived.
Pages now fit that frame; older rows load through Load earlier as before.

The page also words a stream the app ended in plain language instead of
the protocol's reason.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): keep the outline on the relay page share; plainer end words

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the bridge frame cap from its shared definition

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(native-chat): bounded frames for the phone's chat stream

A phone page now asks the host (when it advertises
agent-session.bounded-frames.v1) to keep every agentSession.subscribe
frame within the bridge's 640 KiB frame:

- each row is cut to what the phone draws (tool output to its detail
  length unless it draws as a diff, tool inputs to the diff view's
  length) and then to one row's share of a page, its long strings cut to
  a common length; the row is marked `truncated` with its full size
- history frames carry rows and metadata held to a fixed ceiling; the
  draft queue and the background-task roster go in frames of their own;
  the command list is not sent, since the phone reads it from options
- the queue's admission bound counts whole cards and is sized to one
  frame, so a phone always gets every card whole

Other subscribers get the frames they always did.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): bounded frames review round 1

- the phone waits for the host's capabilities before opening the
  stream, so its opening frame is never sent unbounded
- rows are bounded once, by the reader that sizes the page; the item
  form is an options field and always a function
- one cut rule (`cutLongStrings`) serves the display trims and the fit
- a side list rides an empty history batch rather than a second frame;
  commands changes send a bounded subscriber nothing
- queue admission measures the candidate list once; a legacy queue of
  more cards than fit lists the ones it sends first

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): bounded frames review round 2

- an over-budget legacy queue cuts its text to one common length and
  marks the cards it cut, and no longer names a next card it left out
- one predicate says whether a subscriber is owed the `/` list
- a lighter invariant fixture

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(native-chat): type the shortening guards against named shapes

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): bound every phone's chat stream at the host

The host now bounds frames for every phone (clientKind 'mobile') at the RPC
boundary, so the subscribe param, the host capability and the phone's wait
for the capability probe are gone. Phones of every version already ignore
the stream's slash commands, and a phone reached through the desktop relay
signs in as a phone too.

- A live gap of more than one page reaches a phone as the newest page, so
  no publish outruns the bridge's unacked window; older rows load on scroll.
- Queue admission sizes each card as wide as a failed send can make it.
- Rows are bounded only as a page takes them; the unread row marker is gone
  and a shortened queue card is marked `truncated: true`.
- A bounded subscriber no longer reads or tracks the slash commands.
- The stream-end words are back to main's: `overflow` also covers a page
  that stopped acking, which "too large" would misstate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(native-chat): count every card in line and never shorten one

Queue admission now counts every card the queue can publish again without
passing admission: waiting and returned cards, and sent cards whose delivery
is not yet accepted, which a refusal or a Stop puts back. The fixed per-card
reserve for a failed send's words is gone.

When a phone's list is over its frame room anyway (an older build's larger
queue, or a card that grew after admission), the phone gets the cards it
sends first, whole, and the rest once those leave. Cards are never shortened,
so no app version can Edit a partial copy over the full draft, and the
`truncated` card flag is gone.

Bounded frames strip only the roster from an event; no event carries the
other side lists.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): bound only phone frames; keep main's queue and page sizes

Queue admission is main's again. The phone's list is bounded where it is
sent: the cards it sends first, whole, so every frame fits for any queue
size and no card is shortened.

History pages keep main's size for every reader but phone subscribe frames.
A reader is now its item form plus its page budget, so desktop pages, the
outline and phone history replies (chunked by the app's bridge) are as on
main, and only bounded frames use the bridge frame's share.

Drops the queue's `nextQueuedMessageId` rewrite, which no phone reads, and a
test that duplicated the prefix check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(native-chat): bound phone rows by size alone; final tidy

Rows the journal already caps at a 16 KiB head gain little from a second,
display-shaped cut, and the diff check behind it passed a character count as
a line count. A row is now held to its frame share by shortening alone, and
native-chat-diff is back to main's.

Unexports file-local bounds, fixes a capability-era test comment, and adds
tests for string shortening and a phone's stop-note refresh.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(native-chat): assert a phone's stop-note refresh pages

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(native-chat): run the bounded-frames suite in the Node runtime project

It opens a real SQLite journal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(native-chat): bound legacy dispatch reasons on read

Rows written before the write-side bound carry a provider's full error text,
which every page and phone frame attaches with its submission.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-10-13 13:39:34 -04:00
Neil 7ed23199a5 refactor(orcad-migration): migration source abstraction, capability-gated source.kind, path-rewrite stage (WSL-via-orcad D0) (#27790)
* test(orcad-migration): pin the SSH manifest digest before the source abstraction

* feat(orcad-migration): capability-gated source.kind discriminator, SSH wire unchanged

* refactor(orcad-migration): MigrationSource abstraction with SSH relay as the only source; table-driven path-rewrite stage (identity for SSH)

* fix(orcad-migration): name the wire source type

* chore(orcad-migration): exhaustive path-kind switch; no test assertion

* test(ssh): run the relay migration source's SQLite test in the Node runtime project

* perf(orcad-migration): rename record keys in linear time and skip the walk for the identity

SSH exports run the path-rewrite stage on every stage and commit check; re-reading Object.keys per entry made a 10k-key record cost seconds on the main thread.
2026-10-13 09:23:59 -07:00
Brennan Benson 8be17ddf3a feat(chat): host writes one saved row per subagent (#27947)
* feat(chat): read one saved row per subagent

Adds the singular `subagent` journal block (one row per child, revised in
place, with a plain-text twin for older clients) and teaches every reader
to draw it: desktop and phone fold a turn's sibling rows into the existing
"Ran N subagents" row, the client and off-page rosters name children from
their own rows, the reopen pass settles a row a dead host left working,
and the phone and worker-transcript wires bound it. No producer writes the
block yet; that switch lands in the follow-up.

* feat(chat): host writes one saved row per subagent

Claude, Codex and Grok now only report child-work evidence. The host writes each
subagent's row from its child record, so the chat row, strip and sidebar read one
lifecycle. Deletes the shared subagent tracker, the per-agent rosters and their
caps. An accepted Stop settles only the run it was sent to, provisionally, until
that run reports its own ending. Work the host store has no room to record keeps
the running-tasks strip and its Stop instead of reading as idle.

* fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows

- background is an open boolean so a later build's value never fails a chat load
- a helper's own helpers fold under a groupId of their own, so the phone opens them apart
- each subagent's own row rides along with the page window instead of counting as conversation

* fix(chat): subagent row writer reads the live turn once, stop acks survive a failed batch

- the row writer reads the journal's live turn at most once per change and only for a
  subagent with no saved, queued or in-flight row; a revision keeps its first scope
- a background stop names each accepted task as its answer lands, so a stop-all batch that
  times out or fails part way still ends the runs it was answered for (Claude and Grok)
- the strip's untargeted Stop rule lives in the shared view (offersStopAll); both strips
  inline it again; incomplete tracking no longer holds commands or rewind
- clients admit a frame whose only change is incomplete tracking
- drop the Claude stop's dead publish; the real-CLI stop test asserts the run's own ending
- list the writer test with the real SQLite tests

* fix(chat): a late Grok report never revives an ended helper; Codex keeps announced helpers

- Grok: once a helper's run ended, a later progress or repeated spawn sends no live evidence,
  so its record and saved row stay ended after the next send retires the record; the test rig
  now retires finished records on each turn open, as the host does
- Codex: past the bound, only a child that ran and ended is evicted, so an announced helper
  that has not started keeps its record, row, Stop and parent
- cut: Codex parentTurnId (written, never read), the Claude identities' turn-end forwarding
  (call the corrections directly), the duplicate Grok outcome map
- update stale comments that described the deleted producers

* test(chat): Claude frames to saved row, kept Codex path and usage rules, agent ingestion exemption

- a captured background Claude helper keeps its saved row working past its spawn result and
  the parent turn's end, through the real adapter, host and child records, until its own end
- restore focused tests for the Codex root path, label trim and placeholder, usage before
  announcement and parent usage
- the per-session live limit test uses tasks: subagents are exempt from it

* fix(chat): incomplete tracking follows the refused children, and the strip's Stop reaches them

- the host remembers which children the store refused for room, by handle; each leaves when a
  later report of it is admitted or it reports its own end, so incomplete clears on a truthful
  signal; the provider session's end still clears all, and past 512 a flag holds until then
- a parent row the store refused counts every live child it reports as refused
- with work no record lists, an untargeted strip Stop while no turn runs ends the agent through
  the idle sweep's stop; under a running turn it leaves the agent alone
- the background Stop moves into its own module

* fix(chat): the incomplete Stop is offered for every agent and decides inside its step

- the strip offers the untargeted Stop for work no row lists whenever no turn runs, whatever
  untargeted stop the provider has (ACP agents, Codex before it proves terminal stops), since the
  host reaches it by ending the agent; never under a running turn
- the Stop reads whether work is unlisted and whether a turn runs inside its own step, after the
  listed stops land, so a wake-up turn opened meanwhile is never ended
- Codex executions past the bound evict the oldest ended child, else the oldest never started
- test the writer's unnamed-subagent label; qualify the writer's retry comment

* refactor(chat): the subagent row writer joins the views with their records

Drops the shared host-only row projection and its hook-server read: the writer's wiring reads
the views every surface reads and the records they come from, and takes residency and the
ending's basis from the record.

* refactor(chat): Grok subagent evidence reads its ending through the one outcome map

* refactor(chat): a subagent's group entry as one object

* fix(chat): a background Stop with nothing listed asks the agent for no task stops

So an agent with no stop route (an ACP agent the probe found none for) never refuses before the
Stop ends the work no row lists. Tests: the unlisted case is untargeted and reads unlisted work
inside the step; the repeat-press rig lists the task its Stops reach; reflow the writer header.

* test(chat): a nested helper's saved parent; describe how incomplete tracking clears

* fix(chat): a closed session claims no Stop; a helper's row follows its own running work

- a session close Orca proves is loss of contact, not a Stop the CLI accepted: Claude no longer
  ends what still ran as stopped, so the host settles it unknown and its row reads unverifiable,
  as before this stack; stop-acknowledged comes only from a Stop the CLI accepted
- a helper that finished while work it started still runs is written working, by the strip's
  own rule; once that work ends, or the session ends, it reads its own ending again
- the CLI starting a completed helper again under the same spawn call is the helper waking when
  its work ends: the decoder reports it as a restart, so its one row revises in place and ends
  with its real duration; after any other ending such a start is still read as late
- tests from a captured 2.1.280 frame order; item 4 Stops pin their stop-acknowledged basis

* refactor(chat): drop the unused Stop basis on a child's own ending

Only the acknowledged-Stop edge writes that basis now; reflow the decoder header.

* fix(chat): an older history page keeps a turn's subagent ordinals and row

A page now reaches back to the first sibling of each subagent group it holds part of, at most one more page of conversation, so loading the older page no longer renumbers same-named helpers or moves (and closes) their folded row.
2026-10-12 22:53:42 -07:00
Brennan Benson 564693f176 fix(cli): honor Chat UI setting for worktree agent creation (#27796)
* fix(cli): honor Chat UI setting for worktree agent creation

* fix(cli): preserve older host launch and worktree lineage warnings
2026-10-12 02:14:43 -07:00
Brennan Benson e3ef95dc04 feat(cli): launch agents in existing workspaces (#27794) 2026-10-12 00:38:00 -07:00
Brennan Benson 5035baac50 Edit agent launch settings on the workspace host (#26479)
* Add host-owned agent launch settings mutations

* Edit agent launch defaults on the selected execution host

* Await durable writes for host agent launch settings

* Fence agent detection by execution-host pairing

* Acknowledge host settings before hook reconciliation

* Preserve Reset after pending agent launch saves

* Simplify agent detection re-pair handling to the clear and the transport fence

A re-pair already clears every detected-agent list, flag and pending probe for
the environment, so the per-record pairing owner keys, the owner-keyed pending
probes, the settle-time pairing checks and the hook's ownership gating were
never reached. Detection now captures the pairing before the capability wait
and fences its RPCs with it; the refresh fallback still refuses after a
re-pair. Pins the fence across a capability wait, the needs-update clear, and
the agent-launch RPC permission levels.

* Open launch settings on the Active Server without the focus-routing helper

Main's new owner-routing ratchet counts getActiveRuntimeTarget calls, and this
pane's no-workspace default added one. It now reads the Active Server field the
way the Agents pane did before host selection, so the default is unchanged.

* Re-probe mounted launch surfaces after a re-pair; rely on the transport fence for the refresh fallback

A re-pair clears an environment's detected-agent lists, but a mounted tab bar,
quick launch or composer that was still waiting for its first list (or whose
first probe had failed) never asked again, leaving "Can't reach this
workspace's host" until another surface mounted. The hook now re-runs its
effect when the target environment's pairing revision changes.

The refresh fallback no longer re-checks the pairing in the renderer: it
already carries the pairing captured before the wait, and main refuses any
mismatch. Pins the Settings host form's remount key on re-pair.
2026-10-11 23:26:30 -07:00
Brennan Benson 4526d8bab4 native chat: queued-message actions and question answers accept through their own receipt (#26988)
* feat(native-chat): add a chat-owned command receipt store

Adds the agent_session_command_receipts table and an insert-if-absent
receipt write that runs inside a command's own journal transaction. A
receipt records the command's identity, canonical hash, status and a
pointer to the row holding its result; it dies with its chat record.
Nothing uses it yet: the existing operation ledger stays authoritative.

* fix(native-chat): key command receipts like today's ledger ids

Rows are keyed by caller and operation id; a send looks across callers and
other methods look up only their caller's row, matching the existing ledger.
Bad rows and newer table shapes read as unreadable instead of throwing.

* fix(native-chat): order receipt lookups by insertion and key by operation id

Global lookups now take rows in insertion order, so a read and an insert
agree on which receipt answers an id regardless of the host clock. The
primary key leads with the operation id, so its own index serves both
lookups.

* fix(native-chat): accept sends through their own receipt

Send (direct, queued, steer) and /compact no longer commit a separate
ledger reservation before the message is saved. Their command receipt is
inserted-if-absent in the same transaction as the submission or queued
draft; a repeated id replays from it, a changed request conflicts.
Operation ids are checked for syntax only, so age, clock skew, pruning
and the reservation's own storage can no longer refuse a send.

The ledger success row they already co-committed stays as a temporary
insert-if-absent write so families not yet switched keep seeing these
ids; it is never read on this path.

* fix(native-chat): answer a send's receipt one way, share /compact's id space with Send, wake delivery once

- After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone.
- /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write.
- The receipt route calls its runner directly; the runner wakes delivery when a new message row commits.
- The compatibility ledger row expires from host time; each temporary site is labelled.
- Receipt read errors and post-commit failures are logged.

* test(native-chat): expect receipt answers where tests assumed the send ledger

Restart-continuation tests now model the case Send cannot vouch for (saved but
not loaded into memory) and keep it unconfirmed; a later throw after the message
is in the chat answers continued, as a resend would. Drop /compact's unreachable
legacy replay branch and fix stale comments.

* test: run the queued tests that open the journal database in the Node runtime project

* fix(native-chat): queued-message actions and prompt answers accept through their own receipt

Send Now, Delete, Resume and prompt answers move onto the command-receipt route
Send uses: the receipt is inserted with the command's own write. A no-op answers
only once its receipt commits (otherwise outcome unknown), and a prompt answer's
replay reads the exact revision it was accepted at.

* fix(native-chat): replay a queued action from what its receipt recorded

Send Now's acknowledgement points at the submission it handed off; a question
answer's receipt keeps the accepted resolution; a no-op that cannot save its
receipt answers outcome unknown and holds nothing in memory; classified refusals
pass through.

* docs(native-chat): receipt comments say what a replay answers from

* fix(native-chat): refuse a prompt answer on a newer Orca's journal with the update words

A prompt answer now runs through the same newer-Orca refusal the queued-message actions use, so a read-only journal answers agent_session_journal_unreadable instead of throwing journal_read_only.
2026-10-11 18:25:26 -07:00
Brennan Benson ee34f729be Keep SSH chat providers alive after the desktop disconnects (#26473)
* fix: preserve structured chat work during automatic server stops

* fix: require stop-request support before automatic cleanup

* fix: retain unverified session owners until exit is proven

* fix: preserve unreadable work and retry expired prompt stops

* fix: give unheld reservations and queued sends retirement exits

* fix: clear empty recovery leases and preserve unverified owners in tests

* Keep managed servers alive while chat providers are loaded

* Recheck returning clients before managed idle shutdown

* test: run the managed idle exit chat provider test in the Node runtime

It builds on the native chat rest test rig, which opens a real SQLite database.
2026-10-11 17:24:25 -07:00
Brennan Benson 7dfa4d6e54 fix(native-chat): a new chat's model and effort pickers match their first frame before the agent starts (#27031)
* fix(native-chat): a new chat's effort pill keeps its first-frame value before the agent starts

A chat with no model pick runs the catalog's default model at that model's
default effort. The at-rest options answer now says so for every agent, not
only those whose live child reports the model's default, so the effort pill
no longer blanks and then jumps when the agent starts.

* fix(native-chat): read a chat's at-rest options for the folder it runs in

The at-rest options read asked the model catalog with no workspace, so it
always named the account's default model and effort, even in a repo whose
own agent config could pick another. The new chat's first frame, read for
that repo, names nothing there. The at-rest read now asks for the session's
own folder, so both frames agree on the model and the effort.

* fix(native-chat): scope the at-rest options read only for clients that take no model

A client that needs a model in every options answer (mobile today, older
desktops) refuses an answer naming none, and loses that read's commands with
it. Only a client advertising the optional-model capability gets the answer
for the chat's own folder; any other keeps today's account-default answer.

* test: run the at-rest workspace-scope RPC test in the Node SQLite project

* test: build catalog listings so the fixtures compile before and after main drops the tier map

* fix(native-chat): one at-rest rule — the chat's folder decides its default for every client

The folder-scoped catalog read now decides the default for every client. Where
it names one, the answer names that model and its effort. Where it names none,
a client that takes a model-less answer gets none, and one that needs a model
gets the account default model with no effort: exactly what it got before.

The reader is now required, and the first frame and the at-rest read share one
rule for which locations name a local folder.

* fix(native-chat): keep test callers of the host's options read untouched

Only in-process callers omit the reader at the host's entry, and they read
as the RPC gate treats an in-process caller. The shared read itself still
requires one. This leaves main's newer test call sites unconflicted.

* refactor: one native-local location check for Claude, Codex and the at-rest folder read
2026-10-11 10:46:48 -07:00
OrcaWinandm4air 0024735e82 fix(ssh): remove the relay-only remote workspace sync so reconnect never sticks on pulling (#27409)
* fix(ssh): remove the relay-only remote workspace sync so reconnect never sticks on pulling

* refactor(ssh): drop snapshot apply tokens and terminal finalization only the sync used

* test: drop leftover sync-status mock

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 00:56:48 -07:00
ff27338299 refactor(ssh): connect fails for hosts orcad can't serve; remove the relay client path (#27214)
* wip: connect fails for hosts the managed server can't serve; remove relay session, deploy and relay UI

* wip: delete relay-only client modules left unreachable, prune their tests and gates

* wip: format

* wip: exhaustive unserved message

* wip: drop relay attestation test for the deleted ownership proof

* fix: prune stale baselines; type the test transport stub

* fix(ssh): census proves leftover relay leases; name the remedy for older terminals; translate every connect-failure surface

* test(ssh): leftover leases defer to the host census

* test(e2e): remove relay-path SSH e2e specs and their CI lanes

* test(e2e): drop the auto-convert case that built its relay era through a live relay

* test(e2e): mark relay-era browser and catalog journeys fixme until they seed a profile

* test(e2e): seed relay-era browser and catalog journeys from a saved profile

The three journeys built their old-version state through a connect without an orcad template, which now refuses the host. They now start from a seeded relay-era profile, so the first connect converts the host as an upgrading user's would.

* chore(ci): drop shard timings for the deleted relay tests

* test(e2e): carry a relay-era login cookie across the browser conversion; own every launch

* test(ci): drop the deleted SSH layout oracle from its route

* test(e2e): open the retained browser after conversion, retrying a pre-connect card

* test(e2e): bind the relay-era browser partition before seeding its cookie

A relay-era build always recorded the partition binding before a page stored
data in it. Seeding the cookie into an unbound partition made the binding store
refuse it (browser_route_partition_binding_store_invalid), so the retained tab
showed the generic routing card and Retry could never clear it.

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

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 22:10:33 -07:00
Brennan Benson dab3f6efeb fix(native-chat): preserve running subagent rows in long chats (#26798)
* fix(codex): keep running subagent groups in long chats

* fix(codex): remember superseded settled child turns

* fix(native-chat): retain running Claude subagent groups
2026-10-10 02:03:58 -07:00
Brennan Benson b44a5796c1 fix(native-chat): settle a reply cut off by a server restart (#26460)
* fix: preserve streamed turn duration after host restart

* test: run restart duration regression in Node

* fix: preserve provider receipt time in journal updates

* test: verify provider receipt in Claude ownership assertion

* fix(native-chat): time interrupted turns from real provider output, not flushes

Shared-timeline checkpoints stamped the coalescer flush or retry time as the
provider's latest proof of life, so a turn whose provider died before a delayed
flush settled with an inflated duration after a host restart. Carry each delta's
receipt time through the coalescer and timeline lane.

* fix(native-chat): derive interrupted-turn duration from saved output

A reply cut off by a server restart could show "Worked for 0s" because recovery
ended the turn at the last heartbeat or the reply's first timestamp. End it at
the latest time this owner's output was saved to chat history instead, derived
in the existing journal fold from the row save time. Drops the per-frame
receipt plumbing through every provider and buffer.

* fix(chat): exclude host recovery revisions from provider activity

Mark journal-open and connection-cleanup revisions with the existing recovery
annotation so an interrupted turn does not include host downtime. Preserve
buffered provider output and advance restart fixtures to the restart clock.

* fix(native-chat): interrupt turns a replaced server left running

When the managed server died mid-reply and a new one started on the same
host, the cut-off turn settled `unverifiable` with no end whenever the old
provider's identity could not be proven gone, and nothing ever revised it:
the chat showed no turn status and the sidebar said "unconfirmed".

The new server opens the chat records holding the host's instance lock, so
every runtime that wrote them before is gone, and every provider it held
lost its pipes with it. Record that at load (the fences those runtimes
granted, the loaded owner's last renewal, how its runtime ended) and let
the stale settle end those runtimes' running or unverifiable turns as
interrupted at the owner's last saved output or renewal, with the existing
Orca-stop row. Lease adjudication and the live / unverifiable / exited
owner verdicts are unchanged.

* fix(native-chat): require the record store for any journal open that settles

Only an acquisition's open may skip it; a reader open without the store would leave a replaced server's turns unverifiable.

* fix(native-chat): say a remote host's Orca restarted, not that it was closed

A reply cut off when a remote host's Orca server stopped read "Orca on <host> was closed while
this response was in progress." Nobody closed anything there: the server stopped and Orca started
it again before the row could show. For a chat on a remote environment, cause quit now reads
"Orca on <host> restarted while this response was in progress." The desktop's own quit keeps
"was closed". Renderer-only; no wire change.

* fix(native-chat): say a remote host's Orca stopped, true for a shared desktop too

* test(native-chat): register Grok without compaction in the recovery-output rig

Main's #26502 made Grok declare compaction; the rest-test rig adapter has no compact, so the
registry refused it before any recovery case ran.

* chore(native-chat): say a replaced runtime also revises an unverifiable turn
2026-10-09 17:38:38 -07:00
Brennan Benson 280ef3c6f3 fix(native-chat): accept sends through their own receipt (#26672)
* feat(native-chat): add a chat-owned command receipt store

Adds the agent_session_command_receipts table and an insert-if-absent
receipt write that runs inside a command's own journal transaction. A
receipt records the command's identity, canonical hash, status and a
pointer to the row holding its result; it dies with its chat record.
Nothing uses it yet: the existing operation ledger stays authoritative.

* fix(native-chat): key command receipts like today's ledger ids

Rows are keyed by caller and operation id; a send looks across callers and
other methods look up only their caller's row, matching the existing ledger.
Bad rows and newer table shapes read as unreadable instead of throwing.

* fix(native-chat): order receipt lookups by insertion and key by operation id

Global lookups now take rows in insertion order, so a read and an insert
agree on which receipt answers an id regardless of the host clock. The
primary key leads with the operation id, so its own index serves both
lookups.

* fix(native-chat): accept sends through their own receipt

Send (direct, queued, steer) and /compact no longer commit a separate
ledger reservation before the message is saved. Their command receipt is
inserted-if-absent in the same transaction as the submission or queued
draft; a repeated id replays from it, a changed request conflicts.
Operation ids are checked for syntax only, so age, clock skew, pruning
and the reservation's own storage can no longer refuse a send.

The ledger success row they already co-committed stays as a temporary
insert-if-absent write so families not yet switched keep seeing these
ids; it is never read on this path.

* fix(native-chat): answer a send's receipt one way, share /compact's id space with Send, wake delivery once

- After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone.
- /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write.
- The receipt route calls its runner directly; the runner wakes delivery when a new message row commits.
- The compatibility ledger row expires from host time; each temporary site is labelled.
- Receipt read errors and post-commit failures are logged.

* test(native-chat): expect receipt answers where tests assumed the send ledger

Restart-continuation tests now model the case Send cannot vouch for (saved but
not loaded into memory) and keep it unconfirmed; a later throw after the message
is in the chat answers continued, as a resend would. Drop /compact's unreachable
legacy replay branch and fix stale comments.

* test: run the queued tests that open the journal database in the Node runtime project
2026-10-09 17:25:43 -07:00
Brennan Benson 0f7c06aedb fix(native-chat): identify a paired device by its device id, never its credential (#26777)
A paired device's socket passes its bearer credential as clientId, and native
chat used it as the caller key it stores in ledger rows, receipts, chat records
and prompt answers (shown to other clients). Name a paired device by the device
id its socket proved instead.
2026-10-09 16:26:37 -07:00
Brennan Benson 8defd660c7 A new Pi chat names the model it will run on its first frame (#26821)
* refactor(native-chat): one rule for what a no-pick session says of its configured default

ACP's rule (only a new session that restored no model pick and was sent none
runs its config's default; an effort it picked says nothing) moves beside the
catalog entry types so other agents' adapters report through the same step.
An empty listing (a signed-out agent) now says nothing instead of "no listed
model", so it can never retire a saved default.

* feat(native-chat): check Pi's project config before naming its default model

Pi reads `.pi/settings.json` in the chat's folder and loads project
extensions from `.pi/extensions` (and the `extensions`/`packages` lists).
A settings file that sets defaultProvider, defaultModel,
defaultThinkingLevel, modelThinkingLevels or enabledModels, loads extension
code, or can't be parsed counts as the project picking the model; extension
code may register providers or set the model itself. Key names come from Pi
1.0.4's own settings manager and docs, read without running Pi.

* feat(native-chat): a new Pi chat names the model and thinking level Pi starts on

Pi's model listing marks no model as the one it runs, so a new Pi chat with no
saved pick showed "Model" and then changed to the model its session reported.
A new Pi session (not one resumed or forked from a saved conversation) that
restored no model pick and was sent none now reports the model and, unless it
picked one, the thinking level it started on, through the same host step as
Grok, OpenCode and OMP. The host saves them as the account's default behind
the project-config check, so the next new Pi chat names them on its first
frame. A signed-out Pi, which lists no model, saves nothing.

* fix(native-chat): a newer, coarser effort menu keeps the effort a chat ran

The saved list takes each model's effort menu from the newer listing. Pi's
`--list-models` menu stops at high, while a chat's own menu adds xhigh or max
for models whose thinking map allows them. After a background re-list, a
learned xhigh was no longer in the menu, so the first frame dropped it. When
the configured effort is only in the other listing's menu, that menu is kept.

* fix(native-chat): Pi teaches only the model and thinking level it started on

A Pi chat re-derived its configured default from Pi's current state on every
options read, so a model an extension switched to mid-chat (extensions can
call setModel) was saved as the account default, and the next new chat named
the wrong model. The choice is now computed once, after start-up restores the
chat's saved picks, and every later read reports that snapshot. A pick made in
the chat no longer matters to it. Tests cover an extension switch, a pick made
in the chat, a forked chat, and a learned xhigh surviving a newer re-list.
2026-10-09 13:06:28 -07:00
Brennan Benson 573ff0bca3 Save a new chat's first message during create (#26332)
* fix(native-chat): the host owns the send queue; the window keeps no saved outbox

The desktop kept each structured chat's unsent messages in localStorage and
sent them in order, so one message whose fate was unknown froze every later
send, Retry dropped it silently, failed sends could not be discarded, and an
offline chat could send hours later. The host already records every message
and owns the queue; the window now only sends.

- One in-memory sender for composer, launch prompts and messages sent from
  outside the chat. One send in flight per chat; a transport failure resends
  the same id for up to 30 s; a refusal that proves nothing was recorded puts
  the text back in the composer with the reason; a send that went out and was
  never answered shows an in-doubt line with Send again and holds nothing up.
- The host's "unknown" rows get the same in-doubt line and Send again, from
  the journal, in every window.
- A message an older build left in localStorage is never sent: the host's
  conversation outline decides what goes back to the composer, and the copy is
  deleted once that is saved.

* fix(native-chat): send through the structured chat RPC wrapper, with its timeouts

The sender called the runtime RPC directly, skipping the per-method timeouts
every other structured chat call gets. Only a remote host's request skips the
compatibility check the sender already ran.

* fix(native-chat): an unconfirmed send goes back to the composer, with no new row line

The common pattern draws nothing extra on a message whose delivery is in
doubt once its turn is over, and puts a failed send's text back in the
composer with the reason. So a send nobody answered in time, or one a host
answers in a way that proves nothing, goes back to the composer worded as
unconfirmed, and a host "unknown" row shows nothing extra. Removes the in-doubt
phase, Send again, and its strings.

A host's made-up row for an id its journal lost now reads as unconfirmed, not
as recorded, so that message comes back instead of vanishing.

* test(native-chat): pin that nothing resends a send given back as unconfirmed

* fix(native-chat): sends survive a tab close, never resend after a Stop, and keep remote images

- A normal tab close lets sends on their way settle; what the host never took
  goes back to the conversation's draft. Only a cancelled launch or a worktree
  purge drops them.
- After any Stop, a send already on its way is never sent again under its id:
  a doubtful answer, or a resend that was due, hands its text back worded as
  unconfirmed.
- A returned image keeps the SSH connection it lives on; the connection never
  goes to the host.
- An older build's saved message the host recorded and then rejected is left
  to the host's own row, never handed back.

* fix(native-chat): a Stop or a tab close never resends a send already out, and a kept card is the card's

A Stop that landed while a same-id resend was being readied (its timer
fired, its request not out yet) let that resend go out after the Stop.
The sender now tracks whether a request is awaiting its answer: a Stop
hands back every send between attempts as unconfirmed, lets one whose
request is out settle from its answer, and never issues a request after
it. A normal tab close withdraws the same way instead of doing nothing,
so nothing more goes out and nothing is dropped.

A send the host rejected but kept as a card (keptAsQueuedMessageId) is
the card's, from its reply or the journal, and never goes back to the
composer.

Adds the freeze tests: a send whose fate is unknown holds later sends
no longer than its deadline, and one the host can neither confirm nor
deny releases the next at once.

* fix(native-chat): read a resend's turned-away call or reused id as unproven

Ports the send-answer proof contract. A call the host turned away before
running it (method_not_found, invalid_argument, unauthorized) proves only
that this request wrote nothing, so it reads as never sent on a first
attempt only; on a resend an earlier attempt may have landed, and it goes
again under the same id. A resent id the host says was already used for
other content (messageIdReused) proves nothing either, like an expired or
conflicting id.

Pins the rest of the contract: any row the host returns is its own, a
thrown error is no answer whatever its code, and an older build's
refused, held or outlived-Stop copy is handed back once and never sent.

* refactor(native-chat): type the structured composer's send with its attachment type

Keeps NativeChatStructuredSession.tsx within the file length limit.

* refactor(native-chat): drop the kept-card guards the sender never needed

A kept send's row is a rejection that was never a Stop's, so the sender
already reads it as recorded, from its reply or the journal. The test
that pins it stays; the two extra checks only covered a kept row that is
also a withdrawal, which the host never writes.

* test(native-chat): route launch tests' sends through the client wrapper the sender calls

The sender sends through callStructuredAgentSession, but these launch
tests replaced that module with a factory that answered nothing (and still
named a probe that no longer exists), so every launch prompt resent until
its 30 s deadline and the tests timed out. Each factory now hands sends to
the runtime RPC mock the tests already answer, and expectations of a local
send no longer ask for the remote-only compatibility option.

* fix(native-chat): hand a message back without importing the composer's attachment hook

The worktree purge reaches the launch prompt, which hands text back, and
the attachment hook's imports reach the store. A test that builds the
real store behind a mocked one then waited on itself and hung. Handing
back now writes images to the draft store directly, as the hook's helper
did, and a test pins that each returned image keeps its SSH connection.

* fix(native-chat): one send per chat, with no line of sends behind it

A chat with a send out took further messages into an in-memory line and
sent them one by one. A message typed behind one in doubt then hit its
own 30 s deadline and came back as not sent without ever going out.

Now, as the common pattern does, a chat takes one send at a time: while
it is out, Send is disabled and Enter leaves the text in the box. The
sender refuses a second send instead of lining it up, so the 30 s
deadline always runs from the send itself. A Stop or a tab close stops
the one send: settled from its answer if its request is out, handed back
as unconfirmed between attempts, or silently if it never went out.

Notes sent from outside the chat while its send is out stay with their
sender (not ready); a launch prompt that meets the person's own first
message waits in the composer instead of being lost.

* fix(native-chat): give a Stop-withdrawn note back to its chat once its notes were cleared

Notes sent from outside a chat clear once their message is recorded. A
message the host recorded as pending and a Stop then withdrew came back
only to its sender, which had already let go of it, so the text was
lost. The chat's draft now takes it, as an earlier build's outbox did.

* test(native-chat): type the composer-actions probe without a cast

* chore(native-chat): drop outbox wording left in comments and an empty locale group

* fix(native-chat): pace failed checks, keep the host's reason, and hold the chat for its launch prompt

- A send whose checks failed before its request went out (an unreachable
  or incompatible host, an unreadable history) was tried again at once,
  about a thousand times a second for 30 s. Attempts are now paced by
  the attempts made, whether or not their request went out.
- A host that refuses every resend by throwing (native chat turned off,
  a journal that won't open) gave back only "couldn't confirm". The
  host's reason now comes first, still without claiming not sent.
- A launch's prompt now holds the chat's one send from the click, drawn
  as sending, so a message typed while the chat starts can't overtake
  it; it goes out once the chat exists, and a cancelled launch frees it.
- Notes whose send nobody could confirm say so instead of "did not
  accept", and notes launched into a new chat let go of their text once
  that chat's composer holds it.
- An open chat keeps drawing a recorded send until its row arrives, so a
  reply that beats the history no longer makes the message flicker out.

* fix(native-chat): hold a chat's sends until it has started, and send a failed chat's message with its restart

A message sent to a chat still starting went out at once to a host that
had no record of the chat yet, read for a fence it could not get, and
came back as not sent. A message sent to a chat whose start failed
restarted it but no longer went with the restart.

While a chat starts, Send stays off and Enter leaves the text in the box,
as with a send already out. A text message sent to a chat whose start
failed restarts it and goes as the restart's first message, through the
same staged-prompt path a launch prompt takes: it holds the chat's one
send slot, drawn as sending, until the chat exists, and comes back to
the composer if the restart fails again. Notes wait while a chat starts
and ride a failed chat's restart, keeping their text until it is sent.

* chore(native-chat): test the queue request, rejection words and card hand-offs; drop an unused clear

- Pin which sends ask the host to queue, the moved rejection wording,
  and that a queue send whose card was handed off and then refused or
  withdrawn, or whose replay names a withdrawn card, is never handed
  back.
- The send-at-most-once gate now says what the desktop promises after a
  reload: it never sends the id again, and hands the text back.
- The legacy read names when it goes, and drops the notice clear nothing
  called.

* fix(native-chat): keep a sent launch prompt's entry, and give back at once what can't go out

- Cleaning up a launch prompt released its send slot even after the
  prompt had gone out, which deleted the entry the sender keeps once the
  host records it. A launch prompt recorded as pending and then
  withdrawn by a Stop was lost from both the chat and the box, and an
  open chat dropped the new chat's first message before its row arrived.
  The slot now gives back only a reservation that was never sent.
- A send stopped before its request went out by something trying again
  won't clear (this client and the server can't talk, or the host
  refused the history read) kept Send off for 30 s and then said Orca
  couldn't reach the agent. It now comes back at once with its own
  cause: not sent, since nothing went out, or unconfirmed if an earlier
  attempt did. Transport errors keep the paced retries.

* fix(native-chat): notes keep their own text through a new agent's launch

Notes sent to a New agent whose start failed came back to the notes and
also sat in the new chat's composer, so sending both delivered the text
twice. The notes keep their text until it goes out (they hold it from
the click), so the launch now says so and no composer gets a copy, on a
failed start or a refused prompt alike. The tests that asserted the copy
in the composer pinned the old double ownership and now assert the
notes are its only owner.

Notes that rode a failed chat's restart and ended unconfirmed now say
Orca couldn't confirm them, as notes sent directly do, instead of that
the agent did not accept them.

* chore(native-chat): the send-once gate says what the desktop keeps across a reload

The desktop keeps a send's id in memory only: a send still unsettled at a
reload or crash is not resent and not handed back. The coverage notes no
longer credit the renderer tests with durable identity across a remount.

* fix(native-chat): say once why notes sent to a new agent did not go

Since notes keep their own text through a new agent's launch, a prompt
the host refused, nobody could confirm, or that found the chat's send
taken came back to the notes with nothing said anywhere: the chat shows
no notice for text its caller keeps. The notes menu now reports it once,
with the toast a send to an existing chat already uses: not accepted,
couldn't confirm, or not ready. A start that failed still says so in the
new chat instead.

* fix(native-chat): retry a history refusal the host says clears, and name it at the deadline

A send stopped before its request went out came back at once for any
refusal of the history read, including ones the host names as clearing
(its journal briefly unavailable, a chat detached while the host quits),
so the automatic retry was lost. Only a version mismatch and refusals
that won't clear come back at once now; the rest go again on the paced
schedule, and if the deadline still finds nothing sent, the words are
the host's refusal rather than Orca couldn't reach the agent.

* feat(native-chat): a send makes one request, and nothing ever sends it again

The desktop resent a message under its own id for up to 30 s when the
answer was lost. A send now makes exactly one request, as the common
pattern's clients do:
- An answer that is lost, dropped or proves nothing hands the text back
  at once with "couldn't confirm… check the chat".
- A failure before the request goes out (the environment check, the
  history read for the fence, a refused connection) means nothing went
  out: the text comes back at once with its own reason, or as not sent.
- The 30 s cap stays on the one request, so a host that never answers
  can't hold Send.

Nothing ever resends, so nothing can go out after a Stop: a Stop only
takes back a send still in its pre-send checks. The resend state goes
with it (tries, generation, awaiting, stopped, the resend timer, the
send-answers-proof probe, and the first-attempt/resend split in the
evidence), and the send-once gate says the desktop never resends.

* fix(native-chat): notes keep the chat's line, and a send held behind a rewind says it was not sent

- Only a send whose text belongs to the chat's composer clears the chat's line. Notes sent from
  outside the chat no longer wipe a "couldn't confirm... Check the chat" line that explains text
  already back in the box.
- A send the host turns away behind a rewind it could not confirm went back as "not sent" but said
  "couldn't confirm what happened. Check the chat". It now gives the rewind's reason and says the
  message was not sent.
- Reliability gate names the single-request test and records a fresh evidence run; the sender
  test drops its leftover resend mocks and a duplicate Stop test.

* fix(native-chat): a send ends even when putting its text back fails

If writing the returned text into the chat's draft threw, the send never settled: it stayed
"sending", the chat refused every later send until a reload. The send now always ends after a
hand-back, the failure is logged, and the chat's line adds "Couldn't save your message."

* fix(native-chat): a message typed during /clear stays in the box and follows the chat

A /clear moves the chat to a new conversation, and its host refuses any send while it runs. A
message sent then went out, came back refused into the old conversation's draft with its line, and
vanished when the chat moved on: the box and the line now belong to the new conversation.

- A /clear holds the chat's one send slot while it runs: Enter does nothing, Send shows busy and
  the text stays in the box, as for a send that is out. Notes sent from outside get "busy".
- Once it moves the chat, the old conversation keeps taking no send until the view leaves it, and
  what is left of its draft moves into the new conversation's draft, after anything there: when the
  composer's /clear settles, and again when the view moves.

* fix(native-chat): no "Send message?" or queue clear while the chat's send is out

While a send is out (or a /clear runs) the chat takes no message, yet Enter over a held queue still
opened "Send message?", and Clear queue deleted every card before its message was refused. Enter now
does nothing there and the text stays; Clear queue re-checks and deletes nothing if a send went out
after the question opened.

* refactor(native-chat): take the /clear draft carry out of this change

Moving the old conversation's draft into the one a /clear replaces it with fixes a bug main has too
(text left in the box during a /clear stays with the old conversation), so it goes in its own change.
Kept here: a /clear holds the chat's send slot while it runs, and the conversation it moved away from
takes no send until the view leaves it.

* fix(native-chat): a /clear's hold on sends always ends

- A view that unmounted while a /clear that moves the chat was out left the old conversation
  holding its sends until a reload: the late reply kept the hold for a view that was gone. The
  reply now releases it.
- The local call for a conversation command has no deadline of its own, so a /clear that never
  answers kept Send off for good. The hold now also ends at the command's deadline (195 s, the one
  the remote call already uses), whichever comes first.

* test(native-chat): opening a chat an older build left stuck; hand back its copy in send order

Pins, through the real chat hook, sends, legacy recovery and draft store,
what opening such a chat does: the queued messages behind a message the
host recorded in doubt come back to the composer once, in order, with the
"not sent" or "couldn't confirm" wording; the chat is free to send under
its read's fence; nothing happens while the chat has no fence here.

The legacy reader now hands entries back in the order they were sent
(queuedAt), as the older build's reader did, instead of array order.

* fix(native-chat): a send this window turned away for a re-paired server comes back as not sent

A managed server's update rotates its pairing, and this window's main
process then answers the next call itself, before forwarding anything,
with runtime_environment_changed. The send read that thrown answer as
proving nothing, so the person was told Orca couldn't confirm a message
that never left. It is now handed back as not sent, with the reason.
Every other thrown answer still reads as unconfirmed once the request
may have gone out.

* Save a new chat's first message during create

* Align opening-message types with the user-message wire

* Keep chat publication and cleanup outside store initialization

* Complete the create refusal fixture ledger

* test(native-chat): prove a created first message waits for the agent's start and is handed over once

* refactor(native-chat): keep the host and session hook under the line limit after the main sync

* test(native-chat): a chat created with its first message and no pick still teaches its agent's default

After the main sync, a created chat's agent starts from the delivery loop rather than the create
call. Grok (through the real ACP adapter and model catalog) and Claude (through the real adapter)
each still report the model their own config resolved to once the chat reads its options.
2026-10-09 13:05:35 -07:00
Brennan Benson 39a01f9c0b feat(agent-launch): create a folder workspace and start its agent in one host operation (#26083)
* feat(agent-launch): accept a create-folder-workspace target

agent.launch can now create a folder workspace and start its agent there,
the same operation it already runs for a new git worktree. The host
creates the folder workspace (metadata only, no startup terminal), resolves
it the way an existing target is resolved, then settles the mode and builds
the surface as for any existing folder workspace.

Hosts advertise agent.launch.create-folder-workspace.v1 so a client offers
the target only to a host that accepts it.

* fix(agent-launch): authorize, classify and navigate folder creates like their create method

A create-folder-workspace launch could do three things its two-call
equivalent never did:

- A phone could create folder workspaces. The mobile allowlist admits
  agent.launch but not folderWorkspace.create, and is checked by method
  name only. A create target now needs the authorization of the create
  method it stands for, read from the same allowlist, and a phone is
  refused with agent_launch_target_forbidden before admission, so no
  ledger row and no create. create-worktree maps to worktree.create,
  which phones already may call, so nothing changes for it.
- A create refused before anything was stored (group gone, folder
  missing, SSH host unreachable) replayed as
  agent_session_operation_unknown, and a retry under the same operation
  id was refused until the row expired. runtime.createFolderWorkspace now
  raises those refusals as FolderWorkspaceCreateRefusedError, with the
  same message text, and the launch records it as failed for any target.
  A failure after the create still replays unknown.
- A paired caller's folder create activated the new chat on the host
  window and did not select the caller's own tab. Only create-worktree,
  whose host activation runs setup, keeps the host navigation now; a
  folder create navigates like an existing workspace.

Tests cover the phone refusal, replay of a success, a refused create
settling failed and replaying that failure, an after-create failure
staying unknown, an SSH connection reaching the agent's view mode, paired
navigation, and an older host refusing the target at parse.

* fix(agent-launch): replay a refused folder create with its whole path

A folder create refused before anything was stored is recorded as the
launch's failure, and a retry under the same operation id answers from
that record. The record went through the 128-character cap every launch
failure code gets, and folder refusals carry the folder's absolute path,
so a retry could show the user a path cut short: a different folder.

The refusal is now recorded whole when it fits a path as long as Linux's
PATH_MAX after its code. Past that, which no folder a stat accepts
reaches, only the code before the path is kept, so the replay degrades to
the bare code instead of a partial path.

Also: the controller test asserts the exact refusal message, the
create-folder-workspace capability comment says a phone needs its own
gate, and the forbidden-target code says why it is not `forbidden`.

* test(folder-workspace): type the controller test's store stub without an assertion

The changed-code quality gate refuses the `as never` cast on the stub
store. It is now a RuntimeStore whose unreached members throw.

* test(agent-launch): run the folder-create launch tests in the Node runtime project

The operationId replay tests open the real SQLite record store, so the
file belongs with the other ledger-backed agent.launch tests.

* test(agent-launch): pin that a paired folder create's terminal stays off the host window

The folder-create test now asserts surfaceOwner: false on the terminal a
paired caller's launch creates, rather than matching any options. The
caller-selection header now says only an in-process worktree create keeps
worktree.create's navigation.

* test(agent-launch): drop the per-connection view-mode expectation main removed
2026-10-09 01:23:06 -07:00
Brennan Benson 6ba86414bf Show model and effort choices for every agent before its session starts (#26407)
* feat(native-chat): one saved model catalog for every structured agent

Every agent registration now declares how its models are listed without a
session (a probe, or explicitly none). Probes share one supervised runner
(15 s budget, bounded output, teardown on every path). Grok lists from
initialize (or x.ai/models/list), OpenCode from models --verbose, Pi from
--list-models; Claude moves onto the shared runner. ACP option reads no
longer copy the running model's effort menu or selection onto every model.
Desktop and phone pickers read the same catalog for every agent, with a
non-pickable provider-default model pill before any list exists.

* fix(native-chat): host names a listing's configured model; live listings saved for every agent

- The catalog answer carries an optional listingNamesConfiguredModel field, set
  per registration (Codex, Grok) and per workspace; pickers name the listed
  default from it instead of a Codex-only client flag (older hosts fall back).
- Grok, OpenCode and Pi live option reads save their model list through one
  host step, keeping the account's known default and per-model efforts.
- Phone reads the host catalog like the desktop and seeds every agent.
- Saved picks accept any known agent id (OpenCode, Pi).
- ACP agents with no session-free listing register as unavailable; Grok's live
  effort menu falls back to the session's when the advertised one is empty.

* fix(native-chat): discovery and live listings kept apart; every agent's live listing saved by the host; phone-created chats name their model

* fix(native-chat): phone new-chat mark clears on the chat's first live answer; Claude's startup listing reaches the host catalog

* fix(native-chat): live-listing type lives beside the catalog entry so wire types don't pull the store's persistence into the phone

* fix(native-chat): the model picker never waits on a listing; a first listing lands in place

* feat(native-chat): the host lists every agent's catalog ahead of use and traces each listing

* feat(native-chat): a new Claude chat names the model and effort its config resolves to

* test(native-chat): catalog service fakes gain prewarm

* test(native-chat): prewarm span recorder without a type assertion

* fix(native-chat): project agent config overrides the account default only when it sets a model or effort

A committed .claude/settings.json for permissions or hooks, or the settings.local.json Claude writes on always-allow, no longer hides the configured Claude model in that project. Unreadable config still counts; Codex config.toml follows the same rule.

* test(native-chat): the Codex picker read and the launch it starts resolve one account key, through the real resolvers

Runs resolveHostCodexHomePathForLaunchReadOnly and prepareCodexRuntimeHomeForLaunch as the runtime service wires them, for the system account on the real home, on the shared runtime home, and a managed account. Also scopes the prewarm suite to agents in use.

* fix(native-chat): Claude's configured model and effort are saved together behind the project check; prewarm lists only agents in use and stops with its host

A live Claude chat no longer stamps its effort onto the account catalog; the effort travels with the configured default, which a no-pick chat whose config resolves to no listed model now forgets. Prewarm lists agents with a saved catalog or a chat, and host teardown (quit included) starts no queued listing and stops running ones through an abort signal the listing runners honor.

* test(native-chat): new catalog tests use typed hoisted state and type-only namespace imports

* test(native-chat): a prewarm probe sets and clears the sign-in verdict like a read's, and re-checks an aged one beside a fresh catalog

* fix(native-chat): an aged sign-in verdict re-probes only from probe paths, not a Codex chat's picker

The main merge put the held-verdict check into store.shouldRefresh, which also gates
the live Codex picker. A chat's own listing never clears the verdict, so a signed-out
Codex whose chat works re-listed model/list on every picker read. shouldRefresh is
back to main's rule; the catalog read's probe and prewarm ask probeDue instead.

* fix(native-chat): Claude project settings with an effort cap, a main-thread agent or an Anthropic cloud provider count as an override

maxEffortLevel clamps every effort including the model default, and agent applies that
agent's model (both from the bundled SDK settings type). CLAUDE_CODE_USE_ANTHROPIC_AWS
and CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD sit in the SDK's provider-switch list beside
Bedrock and Vertex. A project that sets only these no longer shows, or saves as the
account default, the account's model or effort.

* refactor(native-chat): the catalog store's picker waiters live in their own module

No behavior change: pendingListing and the waiter notifications move as they were,
which keeps the store under its line limit for the fixes that follow.

* fix(native-chat): changing an agent's command or environment re-lists its catalogs and re-checks its notice

The settings change called prewarm, but prewarm skipped any catalog still fresh (the
catalog key is agent + account, not the command), and a held "not installed" verdict
kept its 30-second window. The change now marks that agent's catalogs due and ages its
held verdicts, so the prewarm it triggers re-lists them and re-checks the notice. Only
agents whose command or env actually changed are touched.

* test(native-chat): the sign-in notice tests check the model picker is really usable

They checked only that a model entry existed, which a disabled or empty pill also
passes. They now check it is settable, carries no disabled reason, and offers the
catalog's or the seed's model.

* fix(native-chat): a probe the host stops keeps the held notice reason, and a stopped prewarm reads nothing

A probe aborted because the host is going away says nothing about the account, so it
no longer replaces a held sign-in or not-installed reason with a plain failure. A
prewarm after stop returns before reading chat records.

* test(codex): the catalog account-key test uses the runtime's real Codex launch-home resolvers

The resolvers move to codex-launch-preparation as one export the runtime service
spreads, so the test runs exactly what the runtime is handed instead of a copy.

* feat(native-chat): keep each host's saved model lists in the renderer

A chat picker could only learn the host's saved model list by reading it
after it mounted, so its first frame was a guess. The renderer now keeps
the host's answers per host and agent: this machine's lists are read once
its runtime answers, for the agents its chats were used with; a paired
host's again when this client regains contact; and an account or launch
setting change drops this machine's lists. Every entry is a host answer.

* fix(native-chat): render the model pills once, from the host's list

A new chat's composer painted its first frame from the built-in model list:
a saved pick that list lacks (Claude's `opus[1m]`) showed as a raw id with
no effort pill, then jumped to "Opus (1M context) · High" when the host's
list landed. Every new chat painted a guess and then corrected it.

Now the first frame reads the host's saved list the renderer already holds,
so it is the final frame. Without one, the quiet "Model" placeholder stands
until the host answers; a host with nothing saved makes the built-in list
usable, naming nothing, until its listing lands. A saved pick is named only
from a host or session list that names it. The phone follows the same rule
and keeps the host's list across a new fence.

* feat(native-chat): answer a saved-only model catalog read

The app could preload a host's saved model lists only for agents with a
saved pick, because the catalog read starts a listing (spawns the agent's
CLI) when nothing is saved. Grok, OpenCode and OMP chats with no saved pick
were never preloaded, so their pickers showed "Model" before the host's list.

`agentSession.modelCatalog` now takes `savedOnly`: the host answers from
what it saved (the entry, configured default and held reason) and starts,
joins or re-checks no listing. Its params are strict, so the host
advertises `agent-session.model-catalog.saved-only.v1` and clients send it
only to hosts that do.

An answer also says when its named default holds in every workspace (Grok
reads no project config for its model), so a new chat in any workspace can
use it on its first frame.

OMP stays without a session-free listing: `omp models --json` lists every
model without the `enabledModels` filter its chats apply.

* fix(native-chat): preload every registered agent's saved model list

Grok, OpenCode and OMP chats painted the "Model" placeholder, then the
host's pills, because the app preloaded only agents with a saved pick.

Now the app reads every agent a host registered through the saved-only
read: this machine once its runtime and agents are known, a paired host at
connect and on each reconnect, and this machine again after an account or
agent-setting change. A host that does not advertise the saved-only read
keeps the earlier rule (saved picks only; a paired host's answered agents).
An agent the host saved nothing for stays unknown. An answer sent before
the host's lists were dropped is not recorded.

* test(native-chat): drop a type assertion from the snapshot sync test

* fix(native-chat): learn an ACP agent's default from a chat started with no pick

Grok's session-free listing named the model `initialize` computed (grok-4.7), but chats ran
another (grok-4.6), so a new chat painted 4.7 and then jumped. The listing no longer names a
default; a new ACP session with no model pick reports what it runs, and the host saves that as
the account's configured default behind the workspace check. A catalog read waits for a
recording still in flight, so the chat's own follow-up read names it.

* feat(native-chat): check OpenCode and OMP project config before naming their default

OpenCode reads opencode.json(c) and .opencode/ in every ancestor of the workspace; OMP merges its
.omp settings and other agents' project settings. A workspace whose config sets a model, effort
or provider key (or can't be parsed) names no default; any other lets a no-pick chat's model
become the account's default, so the next new chat names it on its first frame.

* fix(native-chat): re-read the host's list once a no-pick chat reports its model

The renderer kept the answer it read before the chat started, so the next new chat's first frame
still named no model. A new chat that named no model reads the host's list again once it reports
what it runs; a default no workspace can replace also serves chats in other worktrees.
2026-10-09 01:20:52 -07:00