Commit Graph
11629 Commits
Author SHA1 Message Date
JinjingandSeongho Bae a77f87ea16 feat(sidebar): copy workspace name from context menu (#22338)
* feat(sidebar): copy workspace name from context menu

Add Copy Name directly below Copy Path in the workspace context menu. It
copies the name through resolveWorktreeDisplayName, the renderer mirror of
main's mergeWorktree fallback (custom name, then branch, then folder), so the
copied text is what `name:` worktree selectors resolve against and a cleared
custom name no longer copies `undefined`.

The context-menu model now spreads the command handlers instead of listing
each one twice, which keeps it under the file-length limit.

Fixes #21980
Linear: STA-8068

* refactor(sidebar): rename Copy Name to Copy Worktree Name

- Clarify that this copies the worktree display name, not the path
- Update all locale strings and i18n keys
- Rename test file to match

* test(sidebar): cover folder workspace copy worktree name

---------

Co-authored-by: Seongho Bae <me@seonghobae.me>
2026-09-23 11:25:49 -07:00
Brennan Benson 641a7f36d9 fix(native-chat): keep one live tool-run header from a call's start to the turn's end (#22432)
* fix(native-chat): keep one live tool-run header from a call's start to the turn's end

The collapsed tool run's header was two elements, one for "a call is running"
and one for "nothing is", chosen call by call. Every call start and end
remounted it, the count disappeared while a call ran and came back one
higher, and a call that finished inside a frame still bought the whole swap.
That is the 42→43 flicker in the report.

The header is now one element whose live state belongs to the turn, not to
any call: it stays live from the run's first call until the agent moves past
it (prose, a further run, or the turn's end), and settles in place. While
live the sentence speaks in the present tense and counts the call in flight
("Running 3 commands"), with the latest call's command beside it as a muted
preview; once settled it reads as before ("Ran 3 commands ✓"). The category
glyph is the run's in both states, and the completion mark only appears once
settled, so nothing pops between calls.

Which run is live is derived where the transcript is sliced into rows: the
last row that speaks or acts is the trailing one. A reasoning aside after it
leaves it live; an answer or a further run settles it.

Present-tense forms for the ten sentence categories are added to the shared
copy and the English catalog. The transcript-file lane, which renders with
the structured activity UI off, is unchanged.

* fix(native-chat): settle a run blocked on the reader, keep it live past an approval

- A run whose question is awaiting the reader's answer no longer pulses
  "Reading 1 file" while the agent is blocked; it falls back to its calls.
- An approval's receipt no longer moves past the run above it, so the call
  it just approved reads as running while it runs.
- The header button is the live region, so the count is announced too.
- Drop the unused live option and record from the shared English sentence;
  nothing renders it yet.

* fix(native-chat): stop the settled run's check from fading in on every mount

Windowing remounts settled rows as the reader scrolls, and a restored transcript
mounts them all at once, so the fade replayed where nothing had changed. Also
pin that the live header counts the next call on the same element.
2026-09-23 10:49:04 -07:00
Brennan Benson 563dd5487f feat(native-chat): show a Codex chat's goal above the composer, and set it from goal mode (#22377)
* feat(native-chat): show a Codex chat's goal above the composer and set it from goal mode

Structured Codex chat now treats the thread goal as session state: a banner above the
composer shows the current goal (pursuing / paused) with clear, pause/resume and expand;
/goal enters a goal mode whose send calls thread/goal/set; the objective is journaled as a
user message marked as sent as a goal. The banner is derived from the journaled goal rows,
which Codex's resume snapshot refreshes, so a reopened or adopted chat shows its goal.

Fixes STA-8159

* fix(native-chat): replace a recorded goal by clearing first, and recover a lost goal-change response

- A set while the journal records a goal (any status) clears it before setting,
  so the new goal starts with its own time and token counters instead of
  rewriting the old goal's objective in place.
- The threadGoal plan answers an unknown outcome from the goal the journal
  records and reruns otherwise, so one request timeout no longer refuses every
  later Clear/Pause/Resume as unknown for the mounted session.
- The goal-mode chip says "Exit goal mode"; "Clear goal" stays the banner's
  action on the provider goal.
- A typed bare /goal on Enter enters goal mode, the same as picking it.
- The renderer reads the goal off the tail of its ordered snapshot; the host's
  unordered map keeps the by-sequence reader.
- Drop the composer's duplicate in-flight guard; the goal controller already
  serializes changes.
- Pin that a counter-only revision reaches a subscriber's live page under its
  original sequence.

* fix(native-chat): keep a bare /goal inside goal mode as the entrance, and pin goal delivery and serialization

- A bare `/goal` submitted while already in goal mode re-enters the mode instead
  of setting a goal whose objective is the literal text "/goal".
- The counter-only revision pin now drives the host's own event sink bound to a
  real journal, so it goes red when the publish after a lifecycle transition is
  dropped; the previous fake sink never published.
- Pin that a set which threw after journaling its objective puts that objective
  back exactly once when the ledger reruns the same operation id.
- Cover the goal controller hook: absent without host support, the loaded window
  wins over the host's answer, a second change while one is unsettled answers
  false without a request, and a refused change frees the next one.

* fix(native-chat): resume a blocked or usage-limited goal, and keep goal-mode drafts honest

- The goal bar offers Resume on a blocked or usage-limited goal, which the
  provider resumes exactly as it resumes a paused one; a goal whose token budget
  is spent still offers only Clear. The rule lives beside the other goal facts
  in shared code so every reader answers it the same way.
- A `/goal <text>` typed inside goal mode sets the objective `<text>`, as it
  does outside goal mode, instead of a goal whose objective is the literal
  command.
- Setting a goal is a host round trip; a draft edited while it was in flight is
  no longer wiped when the goal lands, matching every other host command.
- Pin that a lost status-change response is read as applied only when the
  recorded goal is in that status, that a cleared row in the loaded window
  outranks the host's earlier answer, and that the PTY lane is untouched.

* fix(native-chat): keep the load-older anchor on the loaded window when a live revision lands below it

A live revision of a row keeps that row's original sequence. When the row is
older than the client's loaded window, the shared reducer merged it in and it
became the load-older anchor, so paging `before` it skipped every row between.
A goal's counter-only revisions during a long goal turn reach any client that
attached after the goal row left its window, so a reopened chat lost rows on
scroll-back.

The reducer now admits live rows only at or above the window's oldest row
while older rows remain on the host; the journal keeps the revision and the
page reader serves it once the window reaches the row. With nothing older on
the host the window is the whole journal, so a row below the head is admitted
as before.

Also drain accepted provider events before a goal set reads the journal to
decide whether it replaces a recorded goal.
2026-09-23 10:34:06 -07:00
Brennan Benson a375936c04 feat(agent-launch): let a caller reserve the pane its terminal launch creates (#22291)
* feat(agent-launch): let a caller reserve the pane its terminal launch creates

* fix(agent-launch): refuse a launch whose reserved pane is already live

* fix(agent-launch): refuse a live reserved pane before it is revealed

The live-pane refusal used to fire in the executor, after createTerminal
had already issued a handle, published the mobile snapshot and revealed
the tab. The reveal re-registered a fresh launch config over the running
agent's. agent.launch now passes requireFreshPane with a reserved pane,
and createTerminal throws AgentLaunchPaneAlreadyLiveError as soon as
spawn reports it attached to a live pane. That is before any handle,
snapshot or reveal. The spawn reattach itself is the one terminal.create
already uses, so the live PTY is never killed, and the stable-pane
create claim is still released in finally. The isReattach plumbing
added to the launch factory for the old check is gone.

A replay-safe launch refused this way on an existing workspace now
records a failed ledger row, the same way a name collision does.
Before, the row stayed claimed, so every retry got
agent_session_operation_unknown. agent.launchReplay passes the code
through. On create-worktree the workspace already exists when the
terminal is refused, so the row stays unknown. The code is added to
the runtime passthrough list so callers can branch on it.

The pane key is now in the replay fingerprint, deliberately. It is not
placement: group, anchor and focus still stay out of the request and
out of the ledger. It is identity. It is written into the pane's PTY
environment and names the tab the caller has placed. A retry that
reserved a different pane is therefore a different request. Replaying
the first answer would return a key the new reservation can never
find. This matches terminal.createAgentSession, which also fingerprints
its tab and leaf ids. The key is only folded in when present, so every
existing digest is unchanged, and a test pins that.

The wire schema now refuses a tab id the runtime would not adopt as
sent: one with surrounding whitespace, which the runtime trims, and one
longer than 512 characters, which the spawn reservation does not key
on. It reuses the tab-id schema that Placement uses.

* test(agent-launch): pin that a refused live pane issues no handle

The refusal test named handle issuance but only asserted the reveal, so a
throw moved to just before the reveal would still pass. Assert no terminal
is registered, with the attach test as the positive control.
2026-09-23 10:18:16 -07:00
github-actions[bot] dac82f61bc Update README downloads badge 2026-09-23 12:37:55 +00:00
Jinjing 8d6fec597b Optimize cloud-verify workflow to scan HEAD instead of all history (#22457)
* fix(cloud-verify): scan HEAD instead of all history

- Gitleaks now verifies only the checked-out revision
- Reduces scan scope and improves verification workflow performance

* ci(cloud-verify): clarify that Gitleaks scans HEAD-reachable history
2026-09-23 01:36:24 -07:00
Brooks JohnsonandNurdaulet Bolat 942d993f1f fix(editor): map Salesforce Apex extensions to the apex language id (#14287)
Fixes #22049

Co-authored-by: Nurdaulet Bolat <204565446+nvimq@users.noreply.github.com>
2026-09-23 01:29:27 -07:00
Jinwoo Hong ae3d380b23 fix(relay): lock only the target cell row, last and NOWAIT, in the rehome commit (#22449)
* fix(relay): lock only the target cell row, last and NOWAIT, in the rehome commit

The idle-rehome commit runs on the source cell. From an Asia cell each
statement is a cross-region round trip, and the transaction locked every
relay_cells row plus every runtime, capability and safety row before about
twenty more statements, so each Asia-source rehome held the whole fleet's
cell rows for ~3.6 s and every reconnect, renewal and placement queued or
timed out behind it.

The commit now reads the cell inventory and the runtime, capability and
safety tables unlocked, keeps the control and worker rows locked (now
NOWAIT), and takes one cell lock: the target row, in a single statement
that locks it NOWAIT, re-checks enabled, general admission and capacity,
and reserves the units, issued as the last statement before COMMIT. A
target that changed admission, filled up, or is locked by another writer
rolls the whole commit back and answers deferred (candidate-ineligible).

The hold is sampled under a site label, so cellInventoryHoldMsMax still
sees rehome holds and rehomeTargetRowHoldMsMax reports them apart.

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

* fix(relay): fail closed on the rehome target-row lock clause

The target-row statement now carries FOR UPDATE ... NOWAIT unless the
dialect is explicitly SQLite, so a wrapper that omits the optional dialect
can no longer run the reservation unlocked. Test wrappers and the fault
injection entry forward the dialect they wrap.

The latency test also probes the admission and region tables at every
round trip; only the target's admission row may be locked, and only
before COMMIT. The runbook notes that an Asia-sourced commit holds the
rehome control row for about 6.5 s, so a pause that fails once is retried.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 03:45:47 -04:00
Jinwoo Hong a2a78ab335 feat(relay): alert on relay cell table lock convoys (#22446)
* feat(relay): alert on relay cell table lock convoys

Adds a log-based metric and alert for cell-inventory lock holds of at
least 1,000 ms, and a Cloud SQL log metric and alert for relay-only lock
timeout cancels at 20 or more per minute. NOWAIT refusals are excluded:
background sweeps produce about 160 per minute even with rehome paused.

Replayed over 2026-09-20 14:00 to 2026-09-22 15:00 UTC: the hold filter
matches all 93 asia-east2 rehome holds plus 9 director holds, and every
one of the 88 cancel burst minutes overlaps an asia-east2 hold.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): page only on cell lock holds; director holds stay visible

Director holds of 1-2.5 s recur several times a day with rehoming paused,
and pausing rehome does not stop them. The paging hold policy now selects
role=cell samples only; a separate policy with no notification channel
keeps director holds visible. The burst documentation no longer claims no
burst happens while paused, and the runbook points a burst with no cell
hold at the director policy.

Replayed cell-only: 93 of 93 asia-east2 holds, 0 director holds over
2026-09-20 14:00 to 2026-09-22 15:00 UTC; 0 from then to 2026-09-23 07:30.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-23 03:24:09 -04:00
Jinwoo Hong 1043dc5e1d fix(relay): stop rehoming hosts off Asia cells until the lock fix lands (#22443)
The source cell runs the rehome commit. An Asia source pays a cross-ocean
round trip per statement while holding relay_cells row locks every cell
needs, which convoys the fleet database. Selection now drops source cells
outside the director's region before building the decision window, so the
incumbent_region filter shrinks while Asia cells stay valid targets. The
preview counts the same hosts as source-outside-director-region and the
poll summary reports skippedOffRegionSourceCells. Temporary stopgap.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-23 03:15:52 -04:00
Jinwoo Hong 51c3434851 chore(relay): treat Asia cell c30 as a general cell now that it is promoted (#22439)
C30 was promoted to general on 2026-09-23 (selector generation 286). The
same-cap wave now rolls it as a general cell instead of handing it back
isolated, and the shadow gate reads its pool beside C27-C29. Follow-up to
#22375.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 02:55:27 -04:00
Jinwoo Hong 293c2508fc test(mobile): move the session closure pin past the structured tool-line module (#22430)
#22349 added `src/shared/structured-agent-session-tool-call-block.ts`, which the
projection and live turn the session route already reaches import. The PR was
src/shared-only, so its CI never ran the closure suite; main's pin stayed at 4215
while the closure measures 4216.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 02:34:45 -04:00
Neil 17ffbf3b31 fix(runtime): answer startup terminal queries for background-created terminals (#22384)
* fix(runtime): answer startup terminal queries for background-created terminals

A runtime-created terminal (orchestration worker-start, `orca terminal
create`) has no renderer pane until the user opens its tab. Main only
answers terminal queries for PTYs the renderer marked hidden, so on a
fresh app sitting on the landing screen nothing answered the agent's
startup cursor-position query. Muse waits ~2s per unanswered CPR and then
exits 0 with no output, which surfaced as `agent_readiness: timeout`.

Background runtime spawns now carry initiallyHidden, mirroring the
renderer's hidden-at-spawn path: fresh daemon sessions are marked before
byte zero, the committed id is marked and paced as backgrounded, and the
mark is released on failure, reattach, or adoption. A pane that later
mounts visible unmarks and restores from the model snapshot as before.

* fix(runtime): keep background PTYs hidden and paced across renderer reloads

A runtime background spawn has no renderer pane to report visibility or
re-mark it hidden, so it synced as foregrounded (no backpressure thinning)
and a reload/crash gate reset cleared its hidden mark, leaving startup
queries unanswered. Track runtime-owned hidden marks: they survive
renderer-scoped resets, count as known-hidden for backgrounded pacing
until a visible report, and are released by a renderer unmark or PTY
teardown.

* fix(runtime): don't re-hide a background PTY whose view mounted visible during spawn
2026-09-22 23:00:11 -07:00
Jinwoo Hong b864a1c775 test(mobile): repin the recording corpus to main's tip after #22381 (#22407)
#22381 pinned its own branch commit, which the squash left off main; the corpus now pins main at 996f9cc306, the tree its fenced paths match. Every golden changes only its baseline line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 01:53:52 -04:00
Neil 0b16a31e6e fix(runtime): budget explicit terminal close for the daemon's immediate-kill verdict (#22385)
* fix(runtime): budget explicit terminal close for the daemon's immediate-kill verdict

Explicit terminal close (worker-release, worker-stop, `orca terminal close`)
gave the daemon kill RPC a fixed 2s deadline. The daemon's immediate kill
captures descendants, SIGTERMs them with a 2.5s verification window, then
waits up to 8s for the root's physical exit. An agent that runs exit hooks
after SIGTERM (Muse: ~3s) outlived main's 2s timeout, so close reported the
PTY unverifiable and worker-release returned release_unknown even though the
daemon confirmed the exit ~200ms later.

Derive the close budget from the daemon's own immediate-kill reply budget
(now in an import-free module) plus 2s for the post-kill inventory check. A
process that exits within the daemon's budget is released; a wedged process
or unreachable host still times out as unverifiable.

* fix(runtime): budget the force-kill retry and exercise an expired close deadline

* test(runtime): drop tautological deadline-expiry assertion
2026-09-22 22:44:14 -07:00
Neil eb18eaf2b6 feat(usage): add Muse Code local usage provider (#22379)
* feat(usage): add Muse Code local usage provider

Scan Muse session logs (including subagent logs, which hold usage the parent
log does not) for model_completed token events and surface them as a fourth
local usage provider: shared scan worker, persisted per-file cache reused by
mtime/size, cross-log dedupe, Stats tab, and Usage Overview integration.
Muse logs carry no price, so the provider reports tokens only.

* fix(usage): name Muse in Stats & Usage copy; skip partial-cost warning when nothing is priced

* fix(usage): surface unreadable Muse sessions root; name Muse in remaining Stats & Usage copy

* fix(usage): count distinct same-content Muse records within one log
2026-09-22 22:44:10 -07:00
Jinwoo Hong 9fef7a0f04 fix(cloud): gate the Asia canary on its own cell's SQL failures, not the directors' (#22405)
* fix(cloud): gate the Asia canary on its own cell's SQL failures, not the directors'

The production canary summed sqlFailuresDelta over every director and the
canary cell and required zero. Directors log a steady baseline of
relay_cells NOWAIT and lock-timeout refusals unrelated to the canary cell,
so a C30 canary failed most attempts on that noise. The canary now requires
zero SQL failures from the canary cell's own metrics and records the
director sum as directorSqlFailures without gating on it. Directors keep
every other rule (unavailable regions, fallbacks, pool waiting, transient
waiter and wait-time bounds). Staging keeps the combined zero rule and its
evidence shape unchanged.

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

* fix(cloud): gate the Asia canary's pool bounds on its own cell too

Directors also show a steady pool-wait baseline (waiting above zero and
waits over 50 ms in about 6 of every 60 minutes), so a five-minute canary
still failed about half the time on director pool pressure unrelated to
the canary cell. With gateDirectorDatabase off, the production canary now
applies databasePoolWaitingMax, databasePoolWaitersMax and
databasePoolWaitMsMax to the canary cell's metrics only and records the
director values under director-prefixed names. Directors still gate Asia
selections, region fallbacks and unavailable regions. The staging path
keeps its combined values, key order and validation order.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 01:43:26 -04:00
Brennan Benson 8757e40063 fix(native-chat): keep a structured agent's tool line between tool calls (#22349)
* fix(native-chat): keep a structured agent's tool line between tool calls

A structured session's status named a tool only while the call was still
running, so the sidebar's tool line went blank whenever the agent was
thinking or writing between calls. Terminal agents keep naming the finished
tool until the next one starts, and clear it after a failure. The structured
status projection now does the same: a running call wins, otherwise the
turn's newest root call if it completed.

* fix(native-chat): bound the structured tool line by the running turn, not the user row

A send made while a turn is running writes its user row into the journal
straight away, and the turn keeps going. Stopping the scan at that row
blanked the tool line while a tool was still running. The scan now runs to
the turn record and names a call only when that record is still running, so
a turn that already ended never lends its last tool to a pending follow-up.

This lookup was the running-only lookup's only production caller, so it
replaces that lookup instead of sitting beside it.

* fix(native-chat): keep naming a structured agent's failed tool until the next one

Clearing the tool line after a failed call brought the blank gap back for
much of a turn: Codex marks any nonzero exit as failed, so a search with no
match or a red test run is enough. The failure already shows on the tool's
own row in the transcript. The running turn's newest running call still
wins; otherwise its newest root call is named whatever it settled to.

* fix(native-chat): name a structured Codex edit on the tool line as the chat draws it

Once a Codex edit's changes exist, its apply_patch call becomes a diff row,
which the status lookup skipped, so the row named the command before the edit.
The chat's tool-call block for a journal row now comes from one shared builder,
and the status lookup reads the same definition: a diff is named as Diff with
its path, and counts as settled since it carries no lifecycle.

* docs(native-chat): describe the structured tool field as running-or-latest

The status summary's toolName/toolInput now name the running turn's
latest tool between calls, not only a running one. Update the wire type
and status bridge comments that still said "the running tool".
2026-09-22 22:42:31 -07:00
Jinwoo Hong 996f9cc306 feat(mobile-web-bundle): gzipped 384 KiB ranges over a capability-negotiated mobileWeb.bundle.range (OTA phase C follow-up) (#22381)
* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability

Adds mobileWeb.bundle.range with its own strict params and result, so
shipped chunk readers see no reply change. The host gzips each range at
level 6 and sends identity when gzip does not shrink it, sharing the chunk
method's read-slot budget and per-asset verification. status.get
advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the
method is allowlisted for paired phones.

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

* feat(mobile): read the bundle range capability and range replies

Adds the range reply reader and operation, and picks range or chunk from
the status.get capabilities the connection already proved, so an older
desktop keeps being paged in chunks with no probe round trip.

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

* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

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

* chore(mobile): add fflate 0.8.2 for gzip bundle ranges

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

* feat(mobile): decode gzip bundle ranges into a bounded buffer

Inflates each range into a buffer one byte past its window, so a gzip
bomb costs at most that allocation and an overlong body is visible. A
corrupt, truncated or unknown-encoding body refuses as range-undecodable;
a body of the wrong decoded length refuses as range-length-mismatch.

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

* feat(mobile): pass the bundle read method from the session to the fetch

The download reads the capabilities of the gates the reducer decided
under and hands the fetch range or chunk. The fetch does not act on it
yet; the range read lands on the pipelined window.

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

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317368, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

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

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

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

* feat(mobile): read gzipped bundle ranges on the pipelined window

A host that advertised mobileWeb.bundle.range.v1 is paged in 384 KiB
ranges on the range grid, through the same four-read window and queue as
chunks; any other host keeps the chunk grid. Each range is decoded to its
exact window length before the fill checks and the asset hash.

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

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317368 to e94bde327d,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

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

* test(mobile): re-record the corpus at the range-read pin

Repins baseline to the range-read commit and re-records every golden.
Only the baseline and lockfileSha256 headers move: the lockfile gained
fflate, and the bundle-fetch adapter pages the chunk path, whose
requests and replies are unchanged, so no golden body moves.

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

* docs(mobile): state the on-settle reason that holds for pipelined bundle reads

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

* test(mobile): re-pin the recording corpus after the on-settle comment fix

Baseline moves from 252c592b52 to fe41226ef5,
the comment-only commit on a fenced path. Re-recorded: all 787 goldens and
the scenarios file change only their baseline line. No golden body moved.

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

* chore(mobile): keep the lockfile's patch block in main's form

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

* test(mobile): re-pin the recording corpus after the lockfile patch-block restore

Baseline moves from fe41226ef5 to 84d6fca6e7,
which restores main's patchedDependencies form in mobile/pnpm-lock.yaml.
Re-recorded: all 787 goldens change only baseline and lockfileSha256, and
the scenarios file only baseline. No golden body moved.

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

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

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

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

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

* refactor(mobile-web): announce bundle ranges on the manifest reply

The manifest reply now names the range grid in an optional rangeBytes,
beside chunkBytes, and the status capability is gone. The range method
takes exactly the chunk params on that grid instead of a caller length.
Both methods share one verified read that returns the six-field header,
and the range handler checks the connection again before deflating.
Range schemas move into the bundle RPC contract; SHA256_PATTERN is shared
from the manifest contract. Shared refusals are tested once over both
methods.

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

* refactor(mobile): drop the range capability read and its session threading

The phone will read rangeBytes off the loose manifest reply instead, so
the read-method module goes and the session effects and hook return to
the pipeline branch's version. Range imports move to the bundle RPC
contract, the reply reader reuses the shared SHA256_PATTERN, and a new
test pins that node's level-6 gzip from the host encoder inflates with
fflate to the same bytes. The fetch and window-read modules still import
the deleted names until part 2.

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

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f62e. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

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

* feat(mobile): page bundle ranges through one window reader chosen by the manifest

The fetch keeps the pipeline's four-worker pool and builds one window
reader from the manifest reply: ranges on rangeBytes when the host names
it, chunks on chunkBytes otherwise. The reader returns the six-field
header and a lazy bytes() so the stop and misroute checks run before any
decode. A range that inflates to the wrong length now falls to the slot
checks, with the one-byte-over buffer as the memory bound, so
range-length-mismatch is gone. A rangeBytes this build cannot page reads
as absent. Fetch names say window, not chunk.

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

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

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

* test(mobile): re-pin the recording corpus at the window-reader commit

Baseline moves to a25a355546. Re-recorded:
every golden and the scenarios file move only baseline, and the five
bundle-fetch goldens also move lockfileSha256 to this branch's lockfile.
Every golden body is byte-identical to the pipeline branch's. The
recording adapter's scripted host names no rangeBytes, so the bundle
family still records the chunk path.

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

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f62e to 97b13ec7f0.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

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

* test(mobile): re-pin the recording corpus at the second pipeline merge

Baseline moves from a25a355546 to 4d2cab31e5,
the merge of the pipeline's sibling-stop test fix. Re-recorded: every golden
and the scenarios file move only baseline. Against the pipeline branch, only
baseline and lockfileSha256 differ; every golden body is identical.

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

* test(mobile): read the bomb inflation without depending on call order

With the fetch's sibling stop removed, a read left over from the previous
test inflated into the bomb test's record first, and indexOf(601) picked
it. The test now asserts some inflation stopped at 601 and none exceeded
its buffer, whatever else ran.

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

* test(mobile): re-pin the recording corpus at the bomb-test fix

Baseline moves from 4d2cab31e5 to 73fde15487,
the test-only commit on a fenced path. All 787 goldens and the scenarios
file change only their baseline line. No golden body moved.

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

* refactor(mobile-web): tighten the bundle window contract and pin the range sibling stop

The range bomb test reads only its own host's inflations, keyed by the
gzip bodies that host sent, and plans twenty reads so a missing sibling
stop is visible: with stopped.abort() removed it sends all twenty.
The range params are an alias of the chunk params, and the chunk data
bound is the exact base64 length of a full chunk. The phone's chunk and
range replies share one header shape. The window reader closes over the
client and bytes() takes the slot length the fetch computes. The host's
positional read is readMobileWebBundleAssetWindow.

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

* test(mobile): re-pin the recording corpus at the window-contract commit

Baseline moves from 73fde15487 to 7346e005a3.
All 787 goldens and the scenarios file change only their baseline line.
No golden body moved.

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

* test(mobile): re-pin the recording corpus at the main merge

Baseline moves from 7346e005a3 to 9c0fe1a546,
the merge of main at 98a6a5325c. Recorded with --record: all 787 goldens
and the scenarios file change only their baseline line. No golden body
moved.

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

* refactor(mobile-web): drop a test cast and shape-named field maps

The bomb test's inflation log is typed by its hoisted factory's return
instead of an assertion, and the zod field maps shared by the bundle
window schemas are windowParamsFields and windowHeaderFields.

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

* test(mobile): re-pin the recording corpus at the lint fix

Baseline moves from 9c0fe1a546 to f4f0915e70.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

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

* test(mobile): page the range fetch fixture on a small advertised grid

The fake host names a 4 KiB range grid and a 1 KiB chunk grid on its
manifest reply, which the phone pages as it would the real ones, so the
fixtures shrink to a few KiB with the same shapes and each asset is
hashed once. The file runs in about 360 ms instead of 3.5 s, which a
loaded CI runner pushed past the 5 s test timeout. The desktop range
suite still pins that the real host names MOBILE_WEB_BUNDLE_RANGE_BYTES.

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

* test(mobile): re-pin the recording corpus at the range fixture fix

Baseline moves from f4f0915e70 to d4d6aadea2.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 01:34:49 -04:00
Jinjing 49121c32d8 docs(wechat): point community QR code at group 10 (#22403)
Group 9 is full; swap the README QR code and copy (all locales) to the new group 10 invite.
2026-09-22 22:33:16 -07:00
Neil 51d3cafc4f feat(editor): add a setting to turn off preview tabs (#22398)
Single-clicking a file in the Explorer, or following a link in Markdown
source, opens it as a preview tab that the next preview open replaces.
There was no way to turn that off, so browsing files kept swapping one tab.

Adds `editorPreviewTabsEnabled` (General -> Navigation, on by default).
A caller's `preview` flag is now an intent that `resolveEditorPreviewIntent`
resolves against the setting, covering every open path - files, diffs,
history diffs, conflicts - in one place.

Preview-ness is derived rather than reconciled: readers treat a tab as a
preview only when the stored flag and the setting agree, so a flag left
over from a saved session, another window, or a host switch is inert while
previews are off. Nothing rewrites stored flags when the setting changes,
so no settings-landing path has to remember to clean up.

Fixes #22397
2026-09-22 22:33:06 -07:00
Neil 52a1e2875b feat(orchestration): accept Muse model and effort for supervised workers (#22383)
* feat(orchestration): accept Muse model and effort for supervised workers

`worker-start --agent muse` already launched, but `--model` was refused because
Muse had no session-option catalog. Add one that maps worker preferences to
`muse --model <id>` and `--reasoning-effort <level>`; it seeds no models, so
native-chat surfaces show no picker.

opencode stays without `--model`: the opencode 2 TUI (now shipped as
`opencode`) rejects the flag, so the refusal now tells callers to rely on the
agent's own config. Help, skill guide, and docs list valid `--agent` ids and
the agents that accept `--model`.

Refs #19823

* test(mobile): repin session route closure for the Muse option catalog
2026-09-22 22:20:35 -07:00
Neil 0afc66ebd3 fix(opencode2): only treat the question tool's form as a pane blocker (#22399)
* fix(opencode2): only treat the question tool's form as a pane blocker

OpenCode 2 has one form primitive and several producers, and Orca's setup
bridge mapped every `form.created` to `question.asked` — the un-evictable
"the pane owner must answer this" blocker. Against opencode v2.0.12 only
`metadata.kind === "question"` is the agent's question tool; `websearch.provider`
is a provider picker and `mcp-elicitation` is an MCP server prompt raised on
sessionID "global", which is not a session and so can never be retired by that
session going idle.

Admit only the question kind, remember the admitted form ids, and drop
`form.replied`/`form.cancelled` for forms that were never admitted so an
ignored form's resolution cannot retire a live blocker.

Evidence (live v2.0.12 capture, real TUI in a PTY against `opencode serve`)
in docs/bug-reproductions/opencode2-form-created-kinds. That capture also
shows the reported Subagents/Shell/Terminals dock and the agent picker emit
no server event at all, so they were never the `form.created` source.

Refs #22371

* refactor(opencode2): drop the unreachable form-resolution guard

Review was right that the admitted-form-id set defended against nothing.
`clearAttentionForResolution` builds the exact key
[factoryID, "AskUserQuestion", form.id, sourceSessionID] and returns null on a
miss, with no session-wide fallback, and form ids are unique — so a resolution
for a form Orca ignored already matches no live blocker. The guard's comment
claimed a collision the key structure rules out, which is worse than no comment.

Removes the set, its FIFO eviction helper, and the claim; the kind check on
form.created is the whole fix. The end-to-end test stays: it pins the behavior
that an MCP form raised and cancelled leaves a live question blocker standing,
which is the property worth holding regardless of how it is achieved.
2026-09-22 22:08:01 -07:00
Neil ee1c522070 fix(opencode-usage): read OpenCode 2 session_v2 token totals (#22391)
* fix(opencode-usage): read OpenCode 2 session_v2 token totals

OpenCode 2 copies every v1 `session` row into a new `session_v2` table and
then writes only there, so every OpenCode 2 session was invisible to the
usage scanner, which only knew `session`. Reading both tables unfiltered
would double-count the migrated rows, so the scan now builds one
deduplicated session relation where `session_v2` owns any id it shares with
the legacy table, and joins the message-level fallbacks against it too.

Bumps the usage cache schema version so existing caches rescan.

Measured against the real local opencode.db (OpenCode v2.0.12, 234 legacy
`session` rows / 252 `session_v2` rows, 18 of them v2-only):

  migrated db   before: 206 sessions, 367,419,745 tokens, $79.4704
                after:  219 sessions, 374,305,364 tokens, $79.4817
  fresh v2 db   before:   0 sessions,           0 tokens, $0
  (legacy table after:  219 sessions, 374,305,364 tokens, $79.4817
   emptied)

Fixes #15841

* fix(opencode-usage): resolve a migrated session to its fuller row

Review found two ways the fixed `session_v2`-wins precedence loses usage.

A `session_v2` without the token columns scores 0 through the source's
fallback literals, but still excluded the legacy row for every shared id, so
a token-bearing legacy `session` paired with a token-less `session_v2`
returned nothing at all — worse than before this branch. And upstream's
importer recomputes v2 totals from decoded messages, so a session whose
messages fail to decode lands *below* its frozen legacy copy, which the
"legacy is frozen, v2 is newer" rationale did not account for.

Both collapse into one rule: per id, the row with the greater token total
wins, and a tie goes to the higher-priority table. A faithful migration is a
tie, so it still resolves to `session_v2`; a v2 row that lost data no longer
erases the legacy record. This also makes `hasSessionUsageColumns`'s `some`
correct rather than merely lenient — a table without the columns can never
outrank a sibling that has them.

Measured on the real local opencode.db: zero shared ids have a legacy row
that beats its `session_v2` counterpart, so the rule is a no-op on healthy
data and only engages on the degraded shapes above.

Both new tests fail on the previous commit and pass here.
2026-09-22 21:40:49 -07:00
Jinwoo Hong 98a6a5325c test(mobile): repin the recording corpus to main's tip after #22376 (#22394)
#22376 pinned its own branch commit, which the squash left off main; the corpus now pins main at 0c2514e7e0, the tree its fenced paths match. Every golden changes only its baseline line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 00:31:10 -04:00
Jinwoo Hong 483fa0aca2 fix(cloud): compare the Asia topology budget gate against the measured 500-connection default (#22386)
The topology workflow's Cloud SQL gate carried a hard-coded 400 for the
instance's tier default while the consumer contract records the value
measured on the live instance (SHOW max_connections = 500, 2026-09-16,
#21163). The gate compares the two and the first production plan run
(35815654836) failed silently on that mismatch before Terraform ran.

The verified default now lives beside the tier and version it is verified
for, as VERIFIED_DEFAULT_MAX_CONNECTIONS, so the contract and the workflow
are two independent records of the same measurement and the gate keeps
its cross-check. The test pins the new source and forbids a bare literal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 00:28:44 -04:00
Neil 1dcbd4e65d test(opencode): pin the installed OpenCode plugin to a v2-loadable default export (#22389)
OpenCode 2 installs under the plain `opencode` executable name and its plugin
loader requires the default export to carry `setup` (or `effect`) alongside an
`id`; the v1 loader requires `server`. The emitted plugin source already carries
both, but nothing asserted it on the file Orca actually installs into OpenCode's
config directory — the exact surface that regressed in #22234.

Load the installed file as an ES module and assert its default export satisfies
both loaders. Reverting getOpenCodePluginSource() to the v1-only options makes
this test fail with "expected undefined to be type of 'function'".
2026-09-22 21:27:46 -07:00
Jinwoo Hong 0c2514e7e0 perf(mobile): keep four bundle chunk reads in flight across the whole manifest (OTA phase C follow-up) (#22376)
* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

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

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317368, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

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

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

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

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317368 to e94bde327d,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

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

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

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

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

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

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f62e. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

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

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

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

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f62e to 97b13ec7f0.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 00:11:12 -04:00
Jinwoo Hong bf3f95245c feat(relay): declare Asia cell c30 at the c27 shape (#22375)
* feat(relay): declare Asia cell c30 at the c27 shape

Adds production-gce-c30 in asia-east2-a at the reviewed Asia shape (6,000
request units, 3,000/60 connection limits, 16-connection pool, disabled) and
the rehome trust the other Asia cells carry.

Every Asia enumeration now knows C30. The topology, admission, and director
tools treat it as its own reviewed wave so its plan and registration never
touch the live launch cells. C30 promotion requires C27 general and fresh
staging evidence. The topology and director validators now pin the committed
production pool of 16 instead of the stale 10, which had made the topology
workflow reject the committed launch cells.

* fix(relay): plan C30 at live images and prove it with its own canary

The shared URL map pulls every cell into the C30 topology plan, so the workflow
now plans each non-target cell at the image its live template serves, and the
validator names any change to a cell outside the wave. C30 promotion runs the
same five-minute production canary and automatic rollback C27 used, with the
load report proving the canary control was placed on C30, instead of relying
on staging evidence. C30 leaves the shadow gate's fleet pool list until it
serves, rollback rejects mixed partial sets, and a budget test pins the
mixed-Asia-pool refusal.

* fix(relay): pin C30 to the production director's live image digest

C30 promotion requires the director and C30 to report one digest, so C30
takes the director's sha256:4158d8a2 (read 2026-09-22). C27-C29 keep their
committed lines; every Asia check compares only the cells named in a run.

* fix(relay): read the committed cell map from a plan, not console

terraform console evaluates every output against state, and the Relay
deployments output indexes each cell's MIG, so it fails with Invalid index
while C30 is declared but not created. Read the map from a no-refresh,
unlocked plan over the same targets instead, and refuse empty overlay input.

* fix(relay): keep console readers working and C30 migration-only until promotion

relay_gce_cell_deployments indexed each cell's MIG, backend, and template,
so once C30 is declared but not applied every production terraform console
reader printed a warning to stdout and broke its jq parse. Wrap those six
lookups in try(..., null).

Same-cap listed C30 as general, so a rollback dispatch on a migration-only
C30 would restore it with activate and skip its canary. List it with the
migration-only cells until the promotion follow-up moves it.
2026-09-22 23:41:09 -04:00
Jinwoo Hongandlifeodyssey 9af6a3d798 fix(cli): report a denied runtime connection instead of a dead Orca (#22341)
* fix(cli): report a denied runtime connection instead of a dead Orca

Inside Codex's macOS Seatbelt sandbox, connect() on the runtime socket fails
with EPERM. The CLI dropped the errno and reported "Could not connect ...
Restart Orca", appended "Orca is not running. Run 'orca open' first.", and
`orca status` answered ok:true with `starting` (its pid probe also gets EPERM).
An agent following that advice restarts a healthy app, which cannot help.

EPERM/EACCES on the metadata read or the socket/pipe connect now fails with a
CLI-local `runtime_access_denied` error: ok:false, non-zero exit,
operation/systemCode/processState:"unverifiable"/retryable:false and nextSteps
that say to re-run with escalated permissions and not restart. CODEX_SANDBOX
only picks the wording. `orca open` stops before launching.

The status pid probe is unchanged: a refused or missing socket proves the
caller reached the endpoint, so a later EPERM probe is another uid and keeps
#20098's `starting`. Missing, refused, stale-pid and timeout paths are
unchanged.

Adapted from the diagnosis and tests in #20487 (and #19605, #13583).

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>

* docs(skills): tell agents runtime_access_denied means escalate, not restart

The shared CLI-resolution block told every bundled skill to run `orca open`
when a command says Orca is not running. Add the counterpart for the new
access-denied code so sandboxed agents re-run with escalated permissions
instead of launching or restarting Orca. Regenerated stubs and manifest.

* refactor(cli): classify only a denied runtime connect, with a leaner error

A denied metadata read was never observed under a sandbox, and it turned an
unreadable user-data path (the Linux launch contract's root-owned HOME) into
runtime_access_denied instead of "Orca is not running". Keep metadata reads as
on main and classify only the socket/pipe connect.

One helper now maps a socket errno to the error or null; the error data keeps
only systemCode and nextSteps. Tests drop cases already pinned by status.test.ts.

* fix(cli): give not-running advice when a denied socket belongs to a dead Orca

A crashed Orca leaves its metadata and socket file behind, and a sandbox denies
the connect with EPERM before the CLI can see ECONNREFUSED. The sandbox still
reports ESRCH for a gone pid, so a denied connect now probes the metadata pid
and falls through to the ordinary unavailable path when the pid is proven gone.
isProcessRunning moves to its own module so transport and status share it.

* refactor(cli): inline the runtime_access_denied code like other CLI error codes

---------

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>
2026-09-22 22:46:17 -04:00
Neil 83dd047fd9 fix(explorer): find files by name in large local workspaces (#22369)
* fix(explorer): search local workspaces by file name across every file

The Explorer name filter only searched remote workspaces directly; local
workspaces still filtered the first 20,001 listed files, so files beyond
that cap never matched in large repos. Local name queries now rank the
whole workspace on the host, and fall back to an uncapped git listing
when ripgrep is not installed.

* fix(explorer): filter capped local listings on the host with the Explorer word rule

Replaces the Quick Open fuzzy top-32 routing, which dropped multi-word
matches and capped visible results. The Explorer keeps its instant
renderer-side filter; only when the local listing hits its cap does it
re-list on the host with the same word rule applied before the cap.

* fix(explorer): keep capped matches when host name filtering fails

- Fall back to the capped listing (and stop re-listing) if the host scan fails
- Keep primary matches when the ignored-file pass fails during a filtered scan
- Key host scans on normalized filter words; reset capped state per filter session
- Bound nameFilter size at the IPC boundary; drop the double readdir walk

* fix(explorer): match name filters without locale-dependent lowercasing

* fix(explorer): avoid render-time ref writes in the host name filter fallback
2026-09-22 19:39:52 -07:00
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00
Jinwoo HongandDavid Bebawy 7c46a69049 feat(telemetry): report the macOS daemon's code identity on adoption and folder-denial events (#22171)
* feat(daemon): import the macOS process code-identity probe from PR #21826

Takes `daemon-mac-code-identity.ts` and its test verbatim from David Bebawy's
community PR #21826 (stablyai/orca). The probe asks Security.framework, via
`codesign --display --verbose=1 +<pid>`, where a live process's code lives on
disk — the question Node cannot answer, and the one that decides whether tccd
can still resolve a running daemon's code identity after an app update.

Imported unchanged here so the adaptation that follows is reviewable as a diff
against the author's original.

Co-authored-by: David Bebawy <david.ayad2@gmail.com>

* feat(telemetry): report the daemon pid's macOS code identity on the two adoption events

Community PR #21826 argues that macOS terminal daemons lose Documents/Desktop/
Downloads access after an update because the daemon's own executable is
unlinked — Squirrel parks the outgoing bundle under a ShipIt staging directory
and later deletes it — so tccd can no longer map the daemon pid to on-disk
code. Today's `spawner_path_class` and `tcc_attribution` read the binary that
forked the daemon, which an in-place update deletes and recreates, so neither
can see that state.

This adds the detector as a measurement only. `code_identity` rides on
`daemon_adopted` and `daemon_pty_cwd_denied`, the two events that already
describe an adopted daemon, so denied daemons can be cross-tabbed against
healthy ones. Nothing reads the verdict: no replacement, no notice, no UI.

The probe is David Bebawy's, narrowed from a path-carrying union to the closed
enum the wire allows, and memoised per pid so one codesign spawn answers for a
whole daemon generation. Off macOS, or with no pid, it reports `probe-failed`,
which keeps both schemas strict and non-optional.

Co-authored-by: David Bebawy <david.ayad2@gmail.com>

* fix(telemetry): read the daemon's code identity fresh on every adoption event

The probe memoised its verdict per pid and never expired it, so
`daemon_pty_cwd_denied` reported whatever the probe saw at adoption rather than
what was true at the denial. That breaks the measurement in both directions: a
transient codesign failure during startup pinned `probe-failed` for the rest of
the run, and the `parked` to `unresolvable` transition became invisible.
Squirrel leaves the parked bundle in place until the next update, which can be
days, so a daemon adopted as `parked` and denied as `unresolvable` is the exact
crossover this study exists to catch, and the cache hid it.

Now every ask runs its own codesign. Only concurrent asks about the same pid
share a probe, and that entry is cleared as soon as it settles, so nothing
survives to be reported later. Both events are rare enough that one spawn each
is not worth a cache.

* fix(telemetry): drop the dead existence check from the code-identity probe

The classifier stat'd the path codesign displayed and called a missing one
unresolvable. That path is unreachable: once the executable is unlinked,
`codesign --display` prints no `Executable=` line at all and exits 1 with
"No such file or directory", which the fallback below already classifies as
unresolvable. Verified directly on Darwin 25.5 against a signed binary deleted
out from under a running pid.

All the branch actually covered was the window between codesign reading the
path and this process stat'ing it, and it paid for that with a synchronous
stat on the main thread.

* fix(telemetry): never classify a timed-out codesign probe as a verdict

`runProcess` kills the child at the deadline and reports `timedOut`, but the
runner type dropped that field, so a codesign killed mid-display could still
have printed an `Executable=` line and been read as `resolved` or `parked`.
A half-written display proves nothing about where the daemon's code lives.

The runner result now carries `timedOut`, and a timed-out probe returns
`probe-failed` before the output is looked at.

* docs(telemetry): state what each code-identity verdict actually asserts

A reviewer read `resolved` as a claim that the executable sits inside the
installed app and asked for that to be validated. It is not that claim, and we
are not making it: proving containment needs the pid record's spawner path, and
deciding anything from where the code lives is #21826's proposed behaviour
rather than this measurement.

The enum doc now spells out all four verdicts in the terms the probe can
actually support, and says plainly why `resolved` stops at "exists and is not
parked". A matching note sits beside the parked-path pattern.

* docs(telemetry): stop asserting how long a parked bundle survives

The probe's rationale claimed Squirrel keeps the parked bundle "until the next
update". A reviewer claimed the opposite, that it is deleted at the end of the
same install. Neither holds up against this Mac's ShipIt log: the install moves
the outgoing bundle to a TMPDIR ShipIt directory and logs no removal of it at
all, and the one "Couldn't remove owned bundle" line names the incoming
download staging copy, not the parked one. Every parked bundle from the last
two days is nevertheless gone now.

So the rationale in the probe doc, the enum doc, and the reprobe test comment
now assert only what is established: the outgoing bundle is moved aside at
install and disappears later on a schedule we have not pinned down. That is
already enough to justify the design, since one pid's verdict can change
within an app run, which is exactly why every ask reads fresh.

* feat(telemetry): report readable TCC-gated spawns as the code-identity control

`daemon_pty_cwd_denied` gives code_identity's hit rate on denials, but a
readable spawn emitted nothing, so an `unresolvable` adoption with no denial
could not be told apart from a user who never opened a terminal in Documents,
Desktop, or Downloads. The false-positive rate that gates #21826's
auto-replacement was unmeasurable.

`daemon_pty_cwd_readable` now fires when a daemon reads a TCC-gated cwd, once
per daemon and folder class per app run, with the same origin properties as
the denial event. The read-out becomes a 2x2 of code_identity against
readable/denied on protected-folder spawns. Fire-and-forget on the spawn path
like the denial emit, and no app-side directory read.

* refactor(telemetry): one emitter and schema for both cwd verdicts, no dedupe state

The once-per-daemon dedupe on `daemon_pty_cwd_readable` was keyed before the
probe ran, so a daemon first seen readable while `parked` never reported again
once it turned `unresolvable` — the one cell that would count most against
#21826. It also counted per daemon while denials count per spawn, so the 2x2
mixed units.

Readable now reports every spawn, like denied, and both events share one
emitter (`trackDaemonPtyCwdVerdict`) and one schema. The TCC-folder gate lives
in the verdict branch. The origin fields are one shape spread into both
schemas. The codesign probe calls `runProcess` directly and tests mock it,
replacing a test-only runner parameter. The repeated "never cached" rationale
is now said once.

* fix(telemetry): rename the shared origin schema fields for the anti-slop gate

no-shape-in-symbol-names rejects daemonOriginShape; the fields are event props.

---------

Co-authored-by: David Bebawy <david.ayad2@gmail.com>
2026-09-22 22:10:51 -04:00
Brennan Benson 1b85be67d8 feat(native-chat): notify on every settled structured turn (#22105)
* feat(native-chat): notify on every settled structured turn

A structured chat that finished while you were elsewhere lit the sidebar
but never raised an OS notification, and a notification that did arrive
for one could not open the chat it came from.

Unread and delivery now come out of the single resolveAgentAttention
decision the terminal lane already uses: the structured dispatcher calls
applyAgentAttention instead of applyAgentAttentionUnread, so the same
policy that decides what to light also decides what to deliver, through
the same sound and blocked-permission tail.

Every settled turn notifies, as the CLI lane does. Success says
"finished"; failure and cancellation say "stopped" through the shipped
agentInterrupted flag rather than a second vocabulary. A turn whose
outcome the host never stated stays unknown and lights nothing.

The host now dedupes mobile fan-out by event identity (scope, session,
turn) beside the existing per-workspace burst cooldown, so a completion
two windows both saw reaches the phone once while each window still
decides its own banner. Clicking a structured notification reveals the
chat tab: its pane key's leaf is synthetic, so focusTerminal would hunt
a split-layout leaf that does not exist.

* fix(notifications): spend each mobile gate only when it actually notifies

Two review findings on the structured-chat notification lane, both real.

The mobile event gate consumed its reservation before the per-workspace
burst cooldown ran. Two chats in one workspace share that cooldown key,
so the second chat's completion could burn its event key and then lose
the cooldown to the first chat — never announced, yet permanently marked
as announced, so a later window dispatching it could no longer reach the
phone. The gate now peeks first and records the event at dispatch, which
also keeps a known duplicate from burning the cooldown slot.

A notification id is minted from the status row's stateStartedAt, and the
row re-projects that field as the turn settles: the working episode's
start moves into stateHistory and the settled start takes its place. A
banner raised in the window before that re-projection therefore carried
an id acknowledgement never rebuilt, leaving it on screen for good.
Acknowledgement now collects ids for the row's left episodes too — the
same episodes the unread check beside it already scanned, so the two
halves finally read the same turns. Lane-neutral: the terminal lane
mints its ids the same way and had the same gap.

* fix(notifications): drop the mobile event gate and reveal chats in folder workspaces

The per-event mobile dedupe defended against one completion being
dispatched by several Orca windows. Only one renderer mounts the
structured attention bridge, the completion feed is live-only with no
replay, and any in-process duplicate lands inside the existing 5s
per-workspace burst cooldown, which already collapses mobile and
desktop alike. The gate never acted on a real sequence, so the wire
field, the shared ledger and its tests go; mobile delivery is back to
main's behavior.

A folder workspace id ("folder:<id>") has no "repoId::" prefix, so the
click binding was skipped and clicking a chat notification there did
nothing. The chat route selects its workspace itself through
ui:focusEditorTab, so it now binds without a repoId; the terminal
route is unchanged.

* fix(notifications): retire the banner ids actually dispatched, not ids rebuilt from a moved row

A banner's id is minted from the status row's stateStartedAt at dispatch, and that field moves
afterwards: a completion can outrun the settled re-projection, and a settled structured row is
re-stamped with no history entry by any later journal row (a cancel appends a status note after
the turn settles). Rebuilding ids from the row's episodes at acknowledgement missed the second
case and fanned out up to 21 mobile dismissals per pane for ids never raised.

The shared delivery tail now records each dispatched id per subject; acknowledgement retires
those plus the current-row rebuild it always had. The acknowledgement collector is back to
main's single-field form.

* refactor(notifications): retire announced notifications by subject in main

Main now records, per pane, the ids it actually announced (a desktop banner
shown or a phone alert sent) and an acknowledgement passes the acknowledged
pane keys so main retires all of them. This replaces the renderer-side record
of dispatched ids: main is where the announcement happens, so it records only
real announcements, including phone alerts whose desktop banner focus
suppressed. The id rebuilt from the current row stays as the fallback after
a restart empties the in-memory record.
2026-09-22 18:35:18 -07:00
Brennan Benson 0b6f60ac3b fix(worktrees): a listing that predates a create can no longer retire the new workspace (#22311)
* fix(worktrees): a listing that predates a create can no longer retire the new workspace

Creating a workspace while another one was being deleted could land the user on the
empty Landing screen with the new workspace's Claude launch cancelled. A worktree
listing whose scan began before the create finished (the prepared checkout is hidden
from listings while it is still locked) was applied after the create returned, and the
renderer read the new worktree's absence from that authoritative listing as a deletion:
it purged the workspace's tabs, tombstoned the pending structured launch (so the host
session was retired the moment it published) and cleared the active selection.

Renderer: each provider invocation records the local create sequence when it begins
(shared by every caller that coalesces onto it). The merge no longer drops or retires a
worktree this client finished creating after that point, and the change-event diff
purge defers to what the merge actually dropped. Hydrated rows keep being reaped.

Host: a scan a worktree mutation overtook is published as non-authoritative instead of
authoritative, and the create paths invalidate scans as soon as the new worktree is
listable rather than only at the end of the create.

* fix(worktrees): an overtaken scan re-derives the catalog, and the create fence rides every listing

A scan a worktree mutation overtook was published as non-authoritative with a
failure reason, which lit the sidebar's scan-failed indicator, hid external
worktree cards and emptied the visibility dialog after a routine create/delete
overlap, and its early return skipped the abort, currency and dual-owner guards
every other branch honours. The listing now re-scans a bounded number of times
instead (the invalidation already dropped the cache, so it scans afresh or joins
a sibling's scan) and past the bound answers non-authoritative through the same
guard chain, with no failure reason.

The renderer's create-sequence inception is now captured where the provider
invocation starts and released only when it settles, so a waiter released early
cannot reset it for a later joiner, and the direct-SSH scheduler's refresh --
the only listing on hosts with no scan cache -- now carries it too. A re-created
worktree id is re-inserted at the back of the create-record eviction order so
the bound holds.

* fix(worktrees): the host re-runs a scan a worktree change overtook, for cached and SSH listings alike; the renderer create fence is removed

* fix(worktrees): the runtime listing re-runs a scan a worktree change overtook, and a headless host bumps the generation it witnesses

* test(runtime): the change-invalidation suite lists through the public runtime method instead of casting to a protected one

* fix(runtime): the runtime's own worktree change event bumps the scan generation its listing witnesses

A headless host has no window notifier, so a delete (and every other change the runtime announces) never moved the generation the runtime listing re-runs against; a listing the delete overtook published the removed row as authoritative until the next refresh. The bump now rides the runtime's change event, ahead of the send, as the desktop notifier already does.

* fix(worktrees): a listing that spends its re-scan bound logs the churn on the host

Past the bound the listing answered without a trace, so continuous create/delete churn could not be diagnosed from a host log. One warning names the repo and the number of overtaken scans; a caller that withdrew stays silent.
2026-09-22 18:17:19 -07:00
github-actions[bot] 60d12329b2 Update README downloads badge 2026-09-23 00:58:25 +00:00
Jinwoo Hong 4f814c1dd4 fix(browser): scope floating browser shortcuts to their own panel (#22361)
* fix(browser): scope floating browser chrome shortcuts to their own panel

The floating panel rendered its browser with the default 'focused' scope, so
a focused split browser and the floating browser both answered chrome chords.
The floating browser now answers only chords from inside its own overlay, and
a focused split yields chords whose target sits inside the floating panel.

* test(e2e): pin floating browser shortcut scope

* test(e2e): reuse split shortcut fixtures in the floating scope spec
2026-09-22 20:33:53 -04:00
Jinwoo HongandClaude Opus 5.5 2155673da3 fix(usage): price Codex long context per request, not per aggregate (#22360)
* fix(usage): price Codex long context per request, not per aggregate

OpenAI bills a request whose prompt exceeds 272K input tokens at the
long-context rates for that whole request. Orca applied 272K as a
marginal threshold to daily, per-range and per-session token totals,
so ordinary days of short requests were billed mostly at long-context
rates and the summary disagreed with the model breakdown.

Classify each token_count at parse time and carry long-context token
counts through every rollup; the estimate bills that subset at the
long-context rates and the rest at base. Bump the cache schema to 6 so
existing caches rescan and pick up the split.

* fix(usage): never class a total-only Codex delta as long context

A token_count with only running totals yields the increase since the
previous total, which can span several short requests; classing that
aggregate as one >272K request would bill short requests at long-context
rates. Only a last_token_usage record is one response. Also note that
OpenAI's GPT-5.4/5.5 pages say "full session" where 5.6/6 say "full
request"; the per-request reading is the stated assumption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:33:18 -04:00
Jinwoo Hong 11083ac4d3 fix(mobile): the page's auth-failed banner offers Re-pair again (#22363)
#22283 dropped all three banner actions on the page, on the claim that
/pair-scan sits outside the page's route root so Re-pair cannot work there.
It does work: the host screen's router is the route handoff, which posts a
target the page does not serve to the shell (route-handoff.web.ts:207), and
on the emulator the tap opened the native scan screen and Back returned to
the same page document.

The page's sibling now renders Re-pair as native does, wired to the same
onRepair, plus a muted line for the two it still cannot honour: "Reconnect
or remove this host from the Orca app." forceReconnect stays null on the
page and removal keeps refusing; native renders its three actions as
before. The doc comments and the web-overrides reason are corrected, and
the reason's drifted citations re-resolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:24:13 -04:00
Jinwoo Hong dfff3915c4 fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split (#22340)
* fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split

With two browser panes visible in a split, Back, Forward, Reload, Hard
Reload, page zoom and Focus Address Bar fired in every visible pane. Main
forwarded these guest chords without the page id, and each split's active
pane subscribed. The renderer-side listeners for the same chords were also
window-wide per pane, so a key pressed in the toolbar (or in a terminal in
another split) reached every active browser pane.

Guest-forwarded chords now carry the originating browserPageId; preload
admits only well-formed payloads and each pane ignores ids that aren't its
own. Toolbar-path listeners use the same focused-split scope Find already
uses. The streamed remote pane's history chord moves onto that scoped hook.

Cmd/Ctrl+C grab (STA-3319) gets the same scope and no longer arms while a
text selection exists outside the browser pane, so copying from the native
chat transcript works again.

* refactor(browser): simplify split shortcut scoping per review

Drop the preload payload admission (main and preload ship together), fold
the three inline scope checks into browserChromeShortcutOwnsEvent, and
replace the outside-overlay selection check with a plain live-selection
rule so Cmd+C copies from surfaces that do not move split focus.

* refactor(browser): share one zoom command type and tidy shortcut comments

BrowserPageZoomEventDetail and BrowserPageZoomCommand were the same shape;
keep one in shared/browser-page-zoom.ts and route guest and local zoom
through a single handler.

* refactor(browser): narrow the zoom event with instanceof instead of a cast

* test(e2e): pin split-scoped browser shortcuts

Two browser splits (and a terminal beside a browser) now prove that Back,
Forward, Reload, Hard Reload, page zoom, Focus Address Bar, and the element
grab chord act only on the split that sent them, from both the guest page and
the browser toolbar. A native chat selection proves Cmd/Ctrl+C copies instead
of arming grab. Split fixtures move to a shared helper so both specs reuse them.
2026-09-22 20:14:19 -04:00
Jinwoo Hong d11b3e226d fix(mobile): page Back with a dirty markdown draft opens the unsaved-drafts prompt (#22362)
The session's Markdown actions registered BackHandler natively only, so on
the page an unsaved draft left the key unclaimed and the shell's pop dropped
the edits without the prompt. The hook now claims through useBackClaim on
both platforms: always natively, where leaveSession replaces to the host at
the root instead of exiting the app, and on the page only while a draft is
dirty, since an unclaimed press there is already the shell's own leave.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:09:56 -04:00
Jinwoo Hong 3f9582bc90 fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store

Host removal never deleted the removed host's page generation cache, so it sat on disk
until four other hosts were activated and a re-pair could reopen the old tree. Every
caller also minted its own GenerationStore with its own queue, so a removal's index or
update-failure-log write could drop the mounted session's write landing inside it.

The store is now one per process (processGenerationStore, with a reset-for-tests seam);
the shell runtime, host removal and Troubleshoot all share it, and removal deletes the
host's cache after the metadata commits, fire-and-forget beside the failure forget.

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

* test(mobile): the cold-start store per mount is the test's, not production's

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:57:19 -04:00
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

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

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

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

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

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

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

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

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

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

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

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

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

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

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

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

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00
Jinwoo HongandClaude Opus 5.5 e16d9ca597 fix(usage): price GPT-6 Sol/Luna, Opus 5.5 and Fable 5.1, and correct GPT-5.6 rates (#22350)
* fix(usage): price GPT-6 Sol/Luna, Opus 5.5 and Fable 5.1, and correct GPT-5.6 rates

gpt-6-sol and gpt-6-luna had no pricing entry, so their tokens were left
out of the Codex cost total. claude-opus-5-5 and claude-fable-5-1 matched
their major's pattern and were billed at Opus 5 ($5/$25) and Fable 5 ($1
cache read) rates. The GPT-5.6 Sol/Terra/Luna rates no longer matched
OpenAI's published pricing (Luna was 5x high).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(usage): price gpt-5.2-pro, accept (max)/(ultra) tiers, tighten point-release matching

gpt-5.2-pro matched the gpt-5.2 prefix and billed at $1.75/$14 instead of
its $21/$168. Codex's `max` and `ultra` reasoning tiers in parenthesized
form left the model unpriced. The new fable-5-1/opus-5-5 patterns also
accepted a trailing letter, so `claude-fable-5-1m` read as Fable 5.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 19:33:56 -04:00
Brennan Benson 7cb4380179 fix(sidebar): hang the subagent chevron in the card gutter (#22331)
A top-level agent with subagents drew its expand chevron inline before the
status dot, pushing the dot, icon, and title right of every other agent row.
The chevron now hangs in the card's left gutter so all top-level rows share
one status column, and leaf rows no longer reserve a spacer.
2026-09-22 15:48:05 -07:00
Jinwoo Hong 7240368726 feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived

The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.

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

* feat(mobile): record why a hybrid shell update failed, on the device

A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.

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

* feat(mobile): forget a removed host's recorded update failures

Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.

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

* feat(mobile): show recorded update failures in Troubleshoot

A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.

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

* test(mobile): type the update-failure row doubles without casts

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

* fix(mobile): forget a host's update failures once a newer generation commits

The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.

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

* test(mobile): re-pin the session closure for the shared journal producer #22299 added

main at 9ece273056 measures 4212 modules against a pin of 4211: the native-chat fix
added src/shared/agent-session-journal-producer.ts, which three shared modules on the
session route import, and its PR touched nothing under mobile/ so the mobile job never
ran. Re-measured on this merged head and the joiner read off the closure list.

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

* fix(mobile): forget update failures on a download's activation, not a build-id match

The fetch reads the manifest again and commits the build that read named,
so a host that moved between the session's read and the fetch's committed
a build other than requestedBuildId, the forget was skipped, and the stale
"last update failed" line outlived the update that should clear it. The
activating state now says where it came from, download or cache, and the
forget follows a download's activation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 18:35:51 -04:00
AtejiMan c2d9d12b1f fix(cli): describe Linear write support (#21830)
Fixes #21829
2026-09-22 15:03:17 -07:00
Brennan Benson 9ece273056 fix(native-chat): journal rows name the agent that produced them (#22299)
* feat(native-chat): carry producer linkage on every journal row

A journal is the durable record of one agent SESSION, and a session that runs
subagents journals their rows into the same timeline with nothing on the row
saying which agent wrote it. Add that: a per-row linkage bundle naming the
producing agent, its parent, the provider's raw parent reference as provenance,
the kind of work, and which run of the agent produced the row.

The bundle rides the row BASE, not the body: two nested prompt shapes are
strict, so an unknown key on a body makes the whole row parse as malformed. It
is deliberately not a schema-version bump either — an unknown `v` makes a row
unreadable and latches the host read-only, while an unknown key is ignored, so
an older host reads a stamped row and behaves exactly as it does today.

One reader predicate interprets absence, by presence and not by truthiness: an
id that failed to resolve is still an id, and a truthy test would read it as
root and put the child's content back on the parent. The parent-facing status
scans — thinking, the running tool call, the latest assistant line and the
quoted prompt — now skip rows a subagent produced. The transcript is left
unscoped on purpose: it shows every agent's output.

No producer stamps anything yet; this is the carrier and the reader.

* fix(claude): attribute a subagent's journal rows to the subagent

The Claude translator already parsed `parent_tool_use_id` on every envelope and
threw it away. It now resolves that reference to the producing agent's canonical
task id — never to the reference itself, which names the tool CALL and is
re-minted on every resume, so a row stamped with it would split one child into
two the moment it resumed. The raw reference is kept beside it as provenance.

Resolution is its own module rather than more roster: the roster maintains the
spawn-group row a user reads, while this answers, for one frame's parent
reference, whether the rows it produces are the session's own agent's, some
child's, or nobody's yet. It reads the alias table directly to tell "an
announcement named this spawn call" from "this id is simply unknown", which
comparing the canonical id against the raw one cannot do when the two match.

Where the identity is not final the row waits rather than guessing. A top-level
spawn whose `task_started` has not landed is the one case that can still
resolve, so its rows are held — bounded at 64, oldest written first — and
released when the announcement arrives or when nothing can name the producer any
more. Nothing is dropped and nothing is written as the parent's.

A release that announces no tasks at all is decided immediately instead of held:
nothing stable is ever reachable for its children, and an id that rotates is
worse than no id because it is silently wrong rather than visibly absent. Those
rows read as the session's own, exactly as they do today. Holding them instead
would strand every row that REVISES an earlier one — a tool result would leave
its tool row reading "running" for the rest of the turn.

The attempt counter moves in exactly one place, the existing reactivation branch
where a new spawn alias reopens an entry, and is gated on that observed alias
change rather than on the counter, so a late duplicate cannot advance a settled
run. The first run carries no attempt at all.

The group row keeps its own module's write path, now named there, because it is
the one row written from a child's frame that is deliberately the parent's.

* test(native-chat): pin producer linkage end to end, and fix the harnesses first

Four harnesses in this area silently discarded the append options they were
handed, so every assertion about attribution would have passed against
`undefined`. Two are fixed here — the journal double behind the real deferred
sink, and the Claude subagent translator's sink — and each records the options
beside its existing call log rather than on it, so the assertions about call
order stay about call order.

The read side is pinned first, because linkage correct in the store and never
read by the projection is the way this ships looking finished and fixing
nothing. The three defects are asserted through the live-turn and projection
readers: a parent no longer reads as thinking because its child is reasoning, no
longer shows its child's running tool, and no longer quotes its child's prose or
prompt. The opposite direction is pinned too — the transcript still renders the
child's output, and a parent's own line is never suppressed.

Also covered: a resumed child keeps one identity while its spawn call id
rotates; a child row arriving before its announcement is held and then written
linked rather than dropped; a held row is written under the raw reference when
no announcement ever comes; the buffer's bound writes the oldest row rather than
losing it; an unresolvable id reads as a child rather than as the parent; a row
with no linkage reads as root; the schema version is unchanged; and a strict
prompt shape still parses, with a positive control proving that strictness is
real and is why linkage rides the row rather than a body.

* test(native-chat): give the journal double's cast its SAFETY rationale

Editing inside the object literal re-attributes the pre-existing assertion to
changed lines, and the changed-code gate requires a line-specific rationale.

* fix(claude): name the agent that spawned a nested subagent

A grandchild's rows carried an agent id but no parent, and under this
journal's semantics an absent parent is not silence — it is the claim that
the session's own agent spawned the row's producer. For a task spawned from
inside another subagent's sidechain that claim was simply false.

The frames from such a task carry exactly one handle: the nested call's tool
id. That id was journaled once already, as a tool-use block on the row of the
child that made the call, so the child is recoverable from it — but only if
something remembers which row carried it. The registry that already tracks
which tool calls reached the top-level transcript now records the sidechain
ones too, against the reference naming their owner, and the resolver follows
that reference to name a row's parent.

The reference is recorded, not an identity, and it is resolved through the
same path the owner's own rows resolve through, so a parent id always matches
the agent id the parent's rows carry however either was settled. A row now
persists only once BOTH its producer and its parent are final; a grandchild
whose child has not been announced yet waits in the same buffer, and leaves
it through the same three doors.

* test(claude): pin the streamed-text lane's attribution instead of only capturing it

The checkpoint harness was fixed to record the append options, and then nothing
asserted them: all five of its tests passed unchanged against an implementation
that resolves no producer at all, so the lane's attribution was covered by a
capture and no claim.

These assert it: a block streamed inside a child carries that child's linkage,
the session's own carries no keys at all, a checkpoint is held rather than
written while the producing agent is provisional, the flush before settlement
writes a held block under the raw reference rather than losing it or filing it
as the parent's, and a block's producer is resolved once and kept — every
checkpoint rewrites the same row, so a producer that moved would file one
agent's prose under two identities.

* refactor(journal): name the linkage row fields for their role, not their shape

The anti-slop gate rejects "Shape" in a symbol name. These are the linkage
fields a render item carries, and the name now matches the sibling helper
that builds them.

* fix(claude): hold the session's first subagent instead of filing it as the parent

A child's first frames can arrive before the `task_started` that names it, and
the resolver treated "this release has announced no task" as a settled fact
about the CLI. Before its own first announcement every session looks exactly
like that, so the FIRST subagent's pre-announcement rows were written straight
out as the session's own — the whole defect, for the first child of every
session, persisted with no backfill to repair it.

A spawn call the session forwarded at top level is positive evidence that an
announcement is still expected, so it now outranks the release check. A release
that genuinely announces nothing is unchanged: its rows reach the release
verdict at settle and still read as root, just written a little later.

Also stops a malformed owner chain that loops back from naming an agent its own
parent; the depth guard bounded that walk but could not make its answer mean
anything, and absence is the truthful claim.

* fix(claude): read a tool result as its caller's row, not a child's

Every top-level tool call is a forwarded tool id, not just a spawn, so a result
frame naming its own call as parent resolved as a child awaiting an
announcement that is never coming. The row was parked until the turn settled
and the tool sat `running` in the meantime.

A frame delivering the result of the very call it names is the caller consuming
its own output; only a spawn call ever gets a sidechain. Pins added for that and
for the first-subagent hold, and the never-announced case now asserts the row
was WRITTEN as root rather than that it carries no agent id, which an absent row
also satisfied.

* fix(journal): refuse an empty producer id, and drop a bad one without losing the row

The reader that scopes a parent's surfaces tests PRESENCE, so `agentId: ''` is
present: a row carrying it reads as a subagent's and disappears from its own
author's surfaces for good. Neither validator caught it — the wire schema
accepted any string, and the persisted-row guard type-checked nothing in the
bundle at all, against that file's own stated policy.

The wire schema now requires a non-empty id. The persisted side sanitises
instead: a bad linkage field is DROPPED and the row is kept. Rejecting there
would turn a tightened validator into a whole-store kill switch, and degrading
a row to the session's own agent is what every row said before linkage existed.

* fix(native-chat): answer the turn activity line for the session's own agent

`selectStructuredAgentTurnActivity` is a "what is this agent doing right now"
reader and was not scoped by producer. It builds a label set from every
tool-call row in the turn — a subagent's included — and both readers below it
use that set to suppress a line that repeats it. So a CHILD's tool label could
blank the PARENT's activity line: child data deciding the parent's surface.

Live, not latent: the provider-activity branch is populated in this lane, and
it consults the label set without ever consulting `providerFrame`, which is
what the status fallback loop relies on. Scoped once at the top, so both
readers share one interpretation point. Renderer and mobile share this
function, so both are covered.

* fix(journal): stop a lifecycle batch stamping one producer onto N mutations

A lifecycle-batch row carries N mutations but stamped linkage at ROW level, so
a future mixed-producer batch would silently attribute every mutation to
whoever opened it. Both callers are single-producer today, so this was latent.

The write path no longer accepts linkage for a batch, which removes the failure
mode by construction rather than guarding it. The reducer still READS linkage
off a batch row — a row may arrive from a host that writes one — and a genuinely
mixed batch would have to stamp per mutation, which nothing needs yet. Chosen
over adding a per-mutation field because that would persist a new key forever
with no writer and no reader.

* docs(journal): say why each unlinked write site is unlinked, and drop two false claims

Completes the write-site audit the PR claims. Prompt rows carry no linkage and
CANNOT: a prompt arrives through the SDK's permission callback, whose options
carry a request id and the tool awaiting approval and no parent reference of
any kind — unattributable at that site, not deliberately root. Turn rows are
deliberately root and now say so.

Two comments justified decisions by mechanisms this store does not have. The
linkage docblock cited compaction dropping a start row and a pagination
boundary; there is no compaction, and pagination is complete-or-reset. Per-row
repetition is still right, for the reason that is actually true: every reader
scans back from the tail and stops at the turn. A test carried the same false
framing. `claudeFrameParentRef` claimed to read the field by the same rule as
`isRootClaudeFrame`; it is deliberately stricter on the empty string.

* refactor(claude): write a child's rows through, then correct the attribution

Four misattribution paths shared one cause: the lane committed to an
attribution verdict at write time and could never revise it. That followed from
"the journal has no backfill", which is false — re-appending an `itemId` bumps
its revision, the reducer rebuilds linkage from the newest row, and it pins
`sequence`/`observedAt` so a correction does not move the bubble. This lane
already relied on that twice.

So the order inverts. A row whose producer is still provisional is written
immediately, stamped with the spawn call's own id, and re-attributed in place
when the announcement names it. Bookkeeping no longer gates a user's view of
what an agent said.

The hold buffer is deleted rather than left as a pass-through. Corrections are
bounded and die four ways: the announcement, turn settle, teardown, or the
bound. Passing the bound gives up on that producer WHOLESALE — correcting some
of a child's rows and not the rest splits one child across two ids, which is
worse than correcting none. A correction that would change nothing is dropped
rather than burning a revision.

The streamed lane loses its producer latch, which pinned the first verdict
permanently and is why an announcement one frame later could never reach the
row. Every checkpoint rewrites the same identity, so there is one row per block
and re-resolving can only revise it; the latch was guarding against a split
that cannot happen on this path. A block that stops streaming before its
announcement is re-attributed explicitly, since nothing else revisits it, and
the announcement is now observed BEFORE the forced flush that would otherwise
stamp it a line too early.

Also narrows the no-announcements-at-all escape so it no longer swallows a
forwarded spawn call. That escape now applies only to a sidechain id no spawn
call ever forwarded, where there is genuinely no handle to stamp.

* fix(journal): move two test doubles onto the signatures they pin

Both failed typecheck while passing at runtime, which is what a test double
gets to do: vitest never typechecks them.

The sink's lifecycle-batch double still read producer linkage off the batch
input after that input stopped carrying any, so it had no property in common
with the linkage type. The fence is now all it records, which is what the
narrowed contract actually forwards — and what the test beside it already
asserts.

The row-schema helper returned the whole six-arm `JournalRow` union while every
caller reads `body`. It now narrows to the item arm it always builds, so the
assertions read it directly rather than through a cast.

* fix(claude): resolve a tool result to its real caller, not to the session root

A nested tool row could end stuck `running` with its result content dropped.

Cause was in the result-frame attribution, not in the correction ledger. A frame
delivering the result of the call it names as parent is the CALLER consuming its
own output — but the code read "the caller" as "the session's own agent", which
is only true when the caller is the root. A call a child made is owned by that
child. Collapsing it to root both misattributed the row and made the result's
write resolve through a different reference than the call's, so the correction
owed to that row was left holding the body it had BEFORE the result landed, and
re-attribution then reverted the row.

The caller is now resolved through the registry that already records which agent
journaled a tool call, so both writes to one row resolve through the same
reference and the newest body wins.

A settled write also supersedes any correction owed to its row. One `itemId` is
legitimately written under two references — `claudeToolIdentity` is keyed on the
tool id alone — and a settled write already carries a final verdict, so an
outstanding correction could only restamp it from a reference that write did not
use. Dropped rather than re-bodied for that reason.

Adds the ledger's first unit tests, including the invariant this defect broke: a
correction changes a row's attribution and never its content.

* fix(claude): keep a correction owed when the sink refuses it

A correction went out through the plain append, which discards the queue's
admission. Under backpressure the write was refused and `retry` had already
dropped the entry, so the obligation died with nothing re-deriving it — the
failure class this work exists to refuse. It is self-feeding too: a correction
costs a commit on the same serialized writer that carries live rows, so the
burst that generates many corrections is what builds the backlog that drops
them.

It now uses the admission-returning path the sink already exposes, keeps the
entry outstanding on a refusal, and lets `abandon` try once more. A refusal
there ends it: the row keeps the spawn call's own id, which is usable, and an
obligation with no exit is worse than one that settles for less. `settle` also
reports what actually happened instead of always claiming it wrote, so publish
no longer fires for a write nobody accepted.

Also records why the live-turn scans may read the turn record before checking
the producer. A turn is the session's unit of work and no producer of a
turn-bearing body stamps linkage: Claude's turn rows carry none, Codex has no
linkage concept, the compact row passes only a fence, and the stale-turn sweep
goes through the lifecycle-batch path, which cannot carry linkage by type. The
ordering is safe by construction rather than by accident, and the comment says
so, so a future producer knows what it would break.

* fix(claude): never read a row naming a parent as the session's own

A non-null `parent_tool_use_id` names a child, always. The resolver still had
one branch that read such rows as the session's own agent's — a release that
had announced no task, where the comment claimed "there is no handle to stamp".
There is one: the reference itself. The branch was buying a false attribution to
avoid an id nothing joins on, which is the trade already reversed once for
forwarded spawn calls, and every reader of this field is a presence test.

So the branch goes, and with it the `root` arm of the verdict and the resolver's
whole dependency on whether the release announces tasks. Two states remain:
linked now, or linked now and owed a correction. The type deleted a stale test
double on sight, which is the argument for removing the arm rather than the
branch alone.

This also closes the severe half of the tool-origin eviction exposure. A spawn
id evicted from the bounded top-level set used to flip the release check on and
stamp a child's rows as the parent's; with nothing returning root that cannot
happen. What remains is a missed correction, which splits one child across two
ids — the same end state as passing the correction bound, benign in kind and
disclosed.

`isForwardedParentTool` stays where it gates PENDINGNESS. It now decides only
whether a correction is owed, never whether a row is a child's, so a stale
answer costs precision rather than correctness.
2026-09-22 13:57:46 -07:00
Brennan Benson 12040f04d0 refactor(ai-vault): read Codex's stated subagent parentage instead of a boolean (#22298)
* feat(ai-vault): read Codex's stated subagent parentage

Codex states a spawned thread's parentage in `session_meta`: the parent
thread id, the spawn depth, and the agent's nickname, role and naming
path. Add a reader that keeps all five as a typed record.

Releases disagree about where they state it. Newer ones nest the full
record under `source.subagent.thread_spawn` and copy the parent,
nickname and path onto the payload's own keys; 0.144-0.147 name only the
agent's role there and leave those copies as the sole statement of the
parent. Every field is read independently, so a release that states
three of them is not discarded for omitting the other two, and a
malformed field costs only itself.

* refactor(ai-vault): reject Codex worker transcripts on the parentage record

The scanner collapsed Codex's whole spawn record to a yes/no to decide
whether a rollout belonged in Agent Session History. The parse state now
holds the record itself and derives that decision from its presence, so
the parent thread id, depth and agent name survive the scan instead of
being thrown away at the point they are read.

Two behaviour notes. A release that states only `source: { subagent:
'review' }` is now recognised as a spawned thread; the previous check
required that key to be an object, so such a transcript would have shown
up in the user's own history on a release that states no `thread_source`
alongside it. And a transcript that states `thread_source: 'user'` is
still treated as the user's own even if a subagent source sits beside
it, unchanged from before.

* fix(ai-vault): an unreadable subagent source is not a spawn statement

Detection with no stated thread_source accepted any value under
`source.subagent` that was not undefined/null, so `subagent: false` (or 0,
or "") would have read as a spawn and hidden the user's own thread from
Agent Session History. Every release spells a subagent source as either the
spawn record or the agent's role, so readability is the gate: a value that
is neither states nothing. Letting a worker transcript through is visible
and recoverable; dropping a user's session is neither.

* refactor(ai-vault): classify a Codex thread by its source tag, not its role

`source.subagent` is an externally tagged union naming the sort of non-user
thread: a spawn record, but equally a review pass, a compaction, a memory
consolidation, or a labelled `other`. Those are siblings that exist today,
not an older spelling of the spawn record, so reading them is not a legacy
fallback and the tag is not the spawned agent's role — `agent_role` is a
field that exists only inside a spawn record.

Why this is not a tag rename. Why a thread is not the user's own and who
spawned it are two facts, and folding the first into the second made a
compaction read as an agent whose role is "compact". `kind` now carries the
union tag (with `kindLabel` for the free text `other` states) and parentage
stays the join key, so the two can disagree without either being lost. A
transcript is rejected on the classification, never on parentage — which is
also why a forked thread's lineage can never be mistaken for a spawn.

Also read the union faithfully: a tag is a bare string or a single-key
object, and a value that is neither states no tag at all. That keeps a
user's own thread visible on an unreadable value, where the previous
presence test would have hidden it. Adds the documented `agent_type` alias
of `agent_role`, and the payload-level copy of the role that the other three
spawn fields already fell back to.

* fix(ai-vault): hide the machinery Codex runs for itself, not only spawned agents

Codex's `source` is a nested union, and two of its outer tags are not the
user's thread: `subagent` (an agent it spawned, or a review or compaction it
ran) and `internal` (guardian and memory-consolidation machinery). Only the
first was ever read, so an `internal` rollout landed in Agent Session History
as if the user had started it whenever the release omitted `thread_source` —
and that field is optional, absent on 1,315 of 13,137 local rollouts.

Reading the outer tag rather than one hardcoded key also fixes the direction
of the readability rule. The outer tag is the discriminant: nothing but a
non-user source serializes under those keys, so it classifies the thread on
its own, and a kind beneath it that a later release respells no longer leaks
every worker transcript into the user's history. An unreadable value in
`source` itself still states nothing and leaves the thread visible.

Renamed to match what it decides — whether a thread is the user's own, which
was never only about subagents. Every other tag (cli, vscode, exec, mcp,
custom, unknown) is a thread the user started and is now pinned as such.

* refactor(ai-vault): read only the source tag Codex actually writes

Backs out the reader for Codex's other non-user `source` tag. It has zero
records across 13,137 local rollouts, and the threads it would name state a
`thread_source` unconditionally on the path that creates them, so they are
already classified. Reading a shape that has never been observed, for a case
already covered, is speculation — unlike the bare-string subagent tag, which
has 16 real records behind it.

The nested-union reading stays: the outer tag is still what classifies the
thread, so a kind beneath it that a later release respells cannot leak a
worker transcript into the user's history, and the tags the user's own
threads carry stay pinned as visible.

* Revert "refactor(ai-vault): read only the source tag Codex actually writes"

This reverts commit 616a2b7c7d.

Backing the `internal` tag out was right against a bolt-on reading a second
hardcoded key, but not against a reader that decodes the `source` union
generically: there, excluding it means special-casing a documented producer
variant back out of a general reader, and the transcripts it names reach the
user's history on any payload that omits `thread_source` — 1,315 of 13,137
local rollouts. Hiding it costs one entry in the tag set.
2026-09-22 13:56:20 -07:00
Brennan Benson 4c696a1e2a fix(agent-status): a structured session with live child work reads as working (#22295)
* fix(agent-status): a structured session with live child work reads as working

An idle native-chat session whose subagent was still running showed a green
check in the sidebar, the collapsed worktree pill, and worktree ps, while a
terminal Claude session in the same situation showed working. The two lanes
folded child work into the parent's status with different code: the hook
listener did, the structured lane did not.

Both lanes now share one child-work liveness vocabulary and one lead-status
fold. Live agent work makes a settled lead working; shells and monitors alone
make it monitoring. The structured lane derives liveness from the background
task list already on the wire, in both its readers, so the sidebar, the CLI,
the dashboard and mobile agree. The Claude task-kind table is one shared file
covering both the hook inventory and SDK stream names, and the renderer bridge
reuses the shared child-work projection instead of carrying its own copy.

* fix(agent-status): a blocked or out-of-contact subagent still holds its session working

Child-work liveness retired an agent-kind child on any state but working/monitoring,
while the shell beside it stayed live on everything except done/idle. A subagent
waiting on a permission prompt, or one whose host lost contact, therefore counted
for less than a backgrounded sleep and let the session read done. Both kinds now
share the settlement rule `resolveAgentChildWorkFreshness` already reads rows by:
only an explicit done/idle retires child work.

Also keep empty task labels out of the shared background-task projection candidate,
so a host that publishes `name: ''` cannot beat the child-row fallbacks.

* test(agent-status): pin the widened hook-inventory agent names, and correct two stale claims

The hook inventory now classifies through the shared kind table, which also maps the
SDK stream's `local_agent` / `local_subagent`. Nothing pinned that widening, so add
cases for all four agent names — including `teammate`, whose pane state stays `done`
under the #8825 idle-squat rule.

Two comments the fold made false:
- the teardown marker rule's comment claimed it could not disagree with what the UI
  calls working; it is deliberately lead-only, so now it says that and why;
- the agent-status store reference still described the structured row's `state` as the
  deleted `structuredAgentSessionStatusState`, and omitted the `workingMode` the ingest
  now writes.

* fix(agent-status): the state clock restarts when monitoring becomes a real turn

`stateStartedAt` carried forward whenever the prior `state` matched, which was sound
while `state` meant "a turn is running". Now that it folds in child work, an idle lead
watching a `sleep 3600` publishes `working`/`monitoring`; the user's prompt 45 minutes
later keeps `state: 'working'`, so the row inherited the watch loop's clock and read
"Working for 45m" the instant the turn began. Monitoring is its own displayed label
(`worktree-card-compact-agent-row.tsx:40`), so the continuity key is now the whole
published work identity — state AND workingMode — in both writers.

Also record two facts the code stated wrongly: the structured lane's `interrupted: false`
is inert (a projected session status has no interrupted member) rather than a decision,
and the child-work liveness rule's escape hatch is the roster's session lifetime, not a
settled state.

* fix(agent-status): a workflow is watch work, and child work dates itself

Two defects the fold introduced.

`isAgentChildWorkKind` counted `workflow` as agent work, so a structured session
whose only live task was a backgrounded `local_workflow` published a full working
spinner while the children projection — which admits `kind === 'agent'` only —
rendered nothing to expand, and the same workflow in a terminal pane showed the
monitoring badge instead. The repo already decides this: `isClaudeSubagentTask`
excludes workflows by name, and MATERIALIZED_TASK_KINDS leaves "the backgrounded
shell command and the workflow" to the non-agent owner. The predicate is now
`kind === 'agent'`, and the three sites that restated the same test route through
it, so a new kind is decided in one place instead of three that merely agree.

`evidenceObservedAt` dated every row by `summary.updatedAt`, the journal's last
activity. The journal cannot date child work: its clock stopped when the lead's
turn did, so a genuinely live roster aged past the 30-minute staleness window and
mobile's dot decayed a running session to idle. The fold now reports whether child
work alone holds the row open, and only then does the host's observation clock
stand in — keeping "a restart's republish is not new evidence" for lead turns.

* fix(agent-status): the sidebar dates child work the same way the host does

`fromChildWork` reached the host ingest but not the renderer bridge, so after ~30
minutes of live child work with no journal activity the sidebar's row aged into
staleness while `worktree ps` and mobile stayed fresh — two writers for one session
answering differently, which is the defect this PR exists to remove. For a remote
host the client's own receipt time is also the more honest clock, since the journal
stamp is the host's and is never comparable against this machine's now.
2026-09-22 13:48:07 -07:00