Commit Graph
8997 Commits
Author SHA1 Message Date
Jinjing 7ad897cf30 Refactor editor header file rename to a breadcrumb morph UI
- Display full breadcrumb (repo name + parent dirs) during rename for context
- Separate basename field from extension suffix to clarify what users edit
- Replace blur-to-commit with explicit confirm/cancel buttons
- Auto-attach extension to basename; respect explicitly typed extensions
- Add comprehensive tests for rename scenarios and edge cases
2026-09-21 15:44:48 -07:00
Brennan Benson da982a4eb0 fix(native-chat): navigate to open history sessions (#21283)
* fix(native-chat): navigate to open history sessions

* test(native-chat): provide structured session predicate
2026-09-21 15:44:19 -07:00
Jinjing 73b726c64f refactor: use generic VirtualizedList in conflict review (#22092)
Replace SourceControlVirtualFileList with a reusable VirtualizedList component,
and update related constants and test IDs to reflect the generic nature of the
component. This extracts the virtualization logic to a shared utility that can be
used across different features.
2026-09-21 15:24:58 -07:00
Neil 6ee7e9511b fix(wsl): preserve OpenCode agent variant in guest
Preserves the selected OpenCode variant across WSLENV so WSL status detection remains correct when native and WSL installations coexist.
2026-09-21 15:15:30 -07:00
Jinjing 7047cdc0d4 refactor: virtualize artifacts list with reusable component (#22061)
Extract SourceControlVirtualFileList to a generic VirtualizedList component
and apply windowing to the artifacts table for efficient rendering of large
lists. Add aria-setsize and aria-posinset announcements to windowed rows
when opted in.
2026-09-21 15:05:31 -07:00
297cfe0cf3 fix(usage): price GPT-6 Astra, and declare when the Codex cost total omits a model (#22073)
* feat(usage): price GPT-6 Astra token usage

gpt-6-astra was missing from MODEL_PRICING, so normalizeModelForPricing
returned null and estimateCostUsd dropped every event on that model from
the total. Stats & Usage showed ~$0 for hundreds of millions of tokens
with no unpriced indicator, since hasInferredPricing only covers a
missing model name, not a missing table entry.

Rates are the published ones: $10 input / $1 cached input / $50 output
per 1M, with the >272K long-context tier at 2x input and cache and 1.5x
output, which the existing tier fields already express.

Fixes #22005

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

* fix(usage): say when the Codex cost total omits an unpriced model

A daily row whose model has no `MODEL_PRICING` entry gets a null cost, and
`buildSummary` simply skips it. As long as one other row is priced,
`hasAnyBillableCost` is true, so the Codex card prints a confident dollar
figure that silently leaves those tokens out. That is how GPT-6 Astra usage
read as near-$0 before the entry landed, and it is how the next unpriced
model will read too.

`hasInferredPricing` does not cover this: it only fires when a rollout has no
model name at all, and its label ("inferred pricing") describes a guess, not
an omission.

So the summary now carries `hasUnpricedModels`, set when a row has a model
name and no price, and the estimated-cost card appends
"• excludes unpriced models" — the same bullet-suffix idiom the breakdown
rows already use for "• inferred pricing". The number stays; it stops
claiming to be the whole bill.

* fix(usage): caveat the Overview total too, and only when a remainder exists

Review of #22073 found the Codex caveat stopped at the Codex tab. The
Overview tab prints a combined total across providers and already has a
"- some model prices are unavailable" line, but `hasPartialCost` only
noticed a provider whose whole cost was null. A Codex range with one
unpriced model among priced ones kept a real number, so the line stayed
hidden and the Overview repeated the same confident, incomplete figure.
`UsageProviderOverview` now carries `hasPartialCost` — set from
`hasUnpricedModels` for Codex, false for the providers that cannot yet
report it — and the reduction ORs it in. No new string.

Second, the Codex card could read "n/a • excludes unpriced models" when
nothing at all was priced. "Excludes" promises a remainder, and there was
none. The suffix now also requires a non-null total; that case is still
declared, on the Overview, through the null-cost path.

`unpricedCostLabel` becomes `costCardLabel`, since it holds the plain
label whenever there is nothing to qualify.

---------

Co-authored-by: Alfred212121 <58665898+Alfred212121@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:37:50 -04:00
Neil 1a3f4e88c0 fix(opencode): support v2 plugins under plain executable name
Supports OpenCode 2 installed as opencode, including plugin loading and quick-command submission.
2026-09-21 14:29:04 -07:00
Jinwoo Hong 9fea4d1ade fix(rate-limits): keep polling Claude usage for Fable accounts during live sessions (#22071)
* fix(rate-limits): keep polling Claude usage for Fable accounts during live sessions

The statusline feed carries the 5-hour and 7-day windows but never the
Fable weekly window. Each live post rewrote the whole provider snapshot
with a fresh updatedAt, and the automated OAuth poll skipped whenever
that snapshot was under five minutes old. Posts arrive every fifteen
seconds while an agent works, so for accounts with a Fable quota the
meter froze until the session idled.

The skip now applies only when the live feed covers every window the
poll would return, i.e. when the account has no Fable window. Accounts
that have one keep the normal fifteen-minute cadence.

A live post also flipped the snapshot to ok, which dropped the 429
Retry-After and let the next poll land inside the throttle window. The
live snapshot now carries retryAtMs forward and the Retry-After check no
longer depends on the error status.

Fixes STA-8066.

* fix(rate-limits): carry a 429's Retry-After through the live-fresh short-circuit

resolveClaudeFetchApply returns the live snapshot verbatim when a poll
fails while the statusline feed is fresh, so the Retry-After the 429
just reported never reached the poll gate and every cycle re-hit the
throttle. Copy retryAtMs onto the kept snapshot.
2026-09-21 17:25:30 -04:00
Neil 72a1b148c1 fix(settings): make terminal theme selection override Ghostty colors (#22069)
* fix(settings): clear terminal overrides when selecting a theme

* test(settings): cover light terminal theme override reset
2026-09-21 14:10:02 -07:00
Brennan Benson d60043787b feat(agent-launch): carry the launch inputs the host cannot derive (#22037)
* feat(agent-launch): carry the launch inputs the host cannot derive

Desktop's launch call sites cannot move onto `agent.launch` while the wire
drops inputs they depend on. This adds the three the host genuinely cannot
work out for itself, and deliberately adds nothing the host can.

- `agentArgs` — the host read only `settings.agentDefaultArgs`, so a saved
  launch recipe's arguments had no way across. Tri-state is preserved: `null`
  is "no arguments", absent is "use the settings default".
- `cwd` — `TerminalCreateOptions.cwd` already reached the spawn, but nothing
  on the wire filled it. It also decides the route: only a terminal can start
  somewhere other than its workspace, so the host now feeds it to
  `requiresTuiLaunchCommand` and downgrades with `tui_launch_command` rather
  than running a structured session in the wrong directory.
- `launchSource` — telemetry, and the only member of the `agent_started`
  triple the host cannot derive; `agent_kind` and `request_kind` are computed
  host-side. Typed `z.string()`, not the closed enum: params are validated by
  the HOST, so a closed arm set would let an older host refuse a newer
  client's launch over a label. Attribution must not gate a user action.

Not added, because the host already derives them: `launchPlatform`
(`getAgentLaunchPlatformForWorkspace`, from the same connectionId/path/
projectRuntime the renderer uses) and `startupCommandDelivery` (a pure
function of the agent inside `buildAgentStartupPlan`).

Fingerprint: `agentArgs` and `cwd` are in — they change what the call does, so
a retry carrying different ones must conflict rather than replay.
`launchSource` is out — two buttons producing the same launch are one
operation, and folding it in would refuse an honest re-attributed retry. A
caller sending none of the new fields digests exactly as before, because the
canonicalizer drops undefined keys, so launches admitted by an older build
still replay across the upgrade.

Arguments reaching a structured route are ignored by an existing deliberate
decision (the Agent SDK and app-server version their option sets separately
from the interactive CLI), so the host reports it in `warning` instead of
overriding the user's preference on the strength of a field that is not
evidence about the surface.

* fix(agent-launch): forward create-target launch inputs
2026-09-21 13:58:16 -07:00
Brennan Benson 2739246058 feat(native-chat): light the unread indicators when a structured chat finishes (#21924)
* feat(native-chat): light the unread indicators when a structured chat finishes

A structured native chat had no attention producer. The PTY lane reaches the
unread markers through use-notification-dispatch, whose liveness reads PTY
state and whose admission requires terminal panes, so a structured session —
which runs on the execution host with no renderer PTY — could finish a turn
with nothing lighting anywhere. A backgrounded chat was the worst case: with
no mounted pane there was no reader to notice at all.

The host derives the completion, because only the host can. The journal keeps
committing whether or not a renderer holds a reader, so the new feed observes
each commit at StructuredAgentSessionClientDelivery.publishJournal and emits on
every running -> settled transition. That edge runs after the subscriber loop
and independent of it, which is exactly why a chat nobody is watching can still
complete. It is a separate capability-gated stream rather than a field on the
status summary: the summary carries no turn identity and no outcome, and is
re-broadcast on every status change, so folding a completion into it would make
every status consumer a completion consumer.

ONLY `success` LIGHTS ANYTHING. Outcome is A0's provider verdict and is never
inferred: a turn the host merely watched stop carries no outcome and produces
no event, because absent means UNKNOWN. `completed` alone proves nothing — a
provider reports its own API error as a finished turn — so the host emits
nothing for it and the renderer filters again on the way in.

RECOVERY IS LIVE-ONLY. Nothing is retained, queued or replayed on either side.
A subscriber learns what settles while it is subscribed and nothing else; on
reconnect it re-opens an empty stream and whatever landed during the gap is
gone. A retained completion would be a durable "unread is owed" obligation with
nothing to retire it, and a reconnect would then light the dot for work the
user already read. Tests on both sides pin this so a later refactor cannot
quietly turn it into catch-up.

The dot itself reuses the neutral policy in attention/agent-attention-policy
and #21274's structured surface adapter, so suppression, acknowledgement and
addressing keep exactly one implementation and the surface key is never omitted
to evade a check. No second suppression rule is introduced. OS delivery is
deliberately not wired: this calls applyAgentAttentionUnread, not
applyAgentAttention.

Also narrows the completion feed's journal dependency to the newest-turn reader
it actually uses, and adds journal.newestTurn() beside the existing
activeTurnId() on the one shared by-sequence scan rather than a second scan.

* test(cross-version): register the turn-completion subscribe on the wire manifest

The cross-version gate asserts the structured surface's method list by name and
count, so an additive method has to be declared there deliberately. Adding the
entry makes the suite call it in both skew directions and stubs the host side,
which is the statement the gate exists to force.

* fix(native-chat): rebaseline completion feed after rewinds
2026-09-21 13:05:24 -07:00
Brennan Benson dc8cf30554 fix(native-chat): end a structured turn when the agent reports it failed (#22047)
* fix(native-chat): end a structured turn when the provider reports it failed (#22044)

A turn reads as working while its durable turn row says `running`, and only two
events could write a terminal row: the provider's turn-completed notification and
the provider process going away. A provider error that ends a turn is neither, so
the row stayed `running` with nothing re-deriving it, and the chat counted
"Working for N" for the life of the session.

Codex reports such a failure as an `error` notification naming the turn it ended,
with `willRetry` distinguishing it from a stream error it is about to retry. That
frame now settles the turn it names. Claude's CLI reports the same through its
session-state frame, whose `idle` arm the SDK documents as the authoritative
turn-over signal; that now settles the open turn too.

Codex's `thread/status/changed` deliberately settles no open turn: the app server
clears `running` on every error, including ones it reports as not affecting turn
status, so a turn still open there is still running. What it does settle is a send
whose dispatch was never answered — a timed-out dispatch is recorded as unverified
delivery, reads as work still owed, and nothing in a live session retired it.
Retiring it never makes the send re-deliverable.

Splits the codex notification translator so the file stays inside its line budget.

* fix(codex): defer idle dispatch release until turn end

* fix(claude): enable session state lifecycle events
2026-09-21 12:44:13 -07:00
Jinjing f7955e81ff feat(conflict-review): virtualize large conflict file trees (#21920)
Implement windowing for the conflict review file tree using
SourceControlVirtualFileList to efficiently handle large merge conflicts.
Add comprehensive tests for virtualization behavior including scrolling,
collapsing, and dynamic updates.
2026-09-21 12:22:28 -07:00
Brennan Benson 91e6e1f355 fix(native-chat): collapse a finished turn to its answer (#22029)
* fix(native-chat): collapse a finished turn to its answer

A finished turn's "Worked for N" row hid the turn's tool runs and nothing
else. Every sentence the agent said on the way to its answer stayed in the
transcript, so the resting state of a long chat was the narration, not the
reply — one 16m 56s review turn left 21 assistant messages and roughly
seven screens of scrolling behind a control that reads as if it had put
the work away.

The fold's unit is now the turn. A settled turn draws its prompt, its
duration, and its answer; the narration and activity that produced it sit
behind the caret. The answer is the turn's last assistant row that renders
prose — derived, because the journal carries no marker saying which message
is the reply.

Collapsed stays derived rather than stored: nothing closes the disclosure
when a turn ends, it arrives closed because the turn gained a duration. A
running turn therefore folds nothing and the reader watches the work as it
happens, which is what already happened and is now stated rather than
inherited.

Rows that outlive the turn that started them stay outside the fold — a
spawn roster and a background task are often the only record of how that
work ended. So does the reader's own message, question receipts, and the
turn's diff rollup. A turn that produced no prose folds whole, its status
row standing as the anchor.

Two presentation changes come with it, both about the opened view:

- A settled run's header was a call count followed by a monospace list of
  tool names and arguments. It is now one sentence in the transcript's own
  type — "Read 7 files, ran 17 commands, and searched 4 times" — built on
  the tool-category vocabulary that already picks the row's glyph, so the
  words and the icon cannot claim different things. A run of one command
  keeps that command as its header.
- A tool call now owns its result instead of standing beside a separate
  `Result` row, so an opened run lists the work rather than twice as many
  rows half of which say `Result`. Output is one more click. Pairing is
  positional — a result answers the most recent unanswered call — because
  result blocks carry no call identifier to match on.

Command previews also lose the `/bin/zsh -lc "…"` wrapper they all opened
with. The unwrap happens inside `summarizeToolInput`, before truncation,
because the clip at 80 characters removes the closing quote that proves the
wrapper; one site fixes the header, the rows, and the running label.

Measured on a real session journal at 1200x900: the turn above goes from
6,300px across 51 rows to 452px across 2, the whole session from 8,151px
to 2,138px, and the same turn opened from 18,540px to 11,131px.

The fold derivation lives in `src/shared` so the mobile transcript can read
the same rule; wiring mobile's list to it is not part of this change.

* fix(native-chat): preserve FIFO tool result pairing

* fix(native-chat): keep tools collapsed when opening turn

* test(native-chat): clarify independent tool disclosures
2026-09-21 12:13:30 -07:00
Brennan Benson c49b8cd534 Revert "fix(native-chat): stop a subagent's output speaking for the agent tha…" (#22058)
This reverts commit 33ba1ff3df.
2026-09-21 12:11:32 -07:00
Jinjing 7da9788c83 fix(updater): send the gh token and cache the release picker's build list (#21902)
* fix(updater): send the gh token and cache the release picker's build list

The dev build picker listed releases through api.github.com with no
Authorization header, so it spent GitHub's 60/hour per-IP bucket that every
unauthenticated caller on the same network shares, and it refetched on every
settings mount and channel click. When that bucket ran dry the picker showed
"No builds found" with a rate-limit line even though GitHub was healthy and
the user's own token had its full quota.

Attach the local `gh auth token` when there is one so the request draws from
the user's 5000/hour bucket, fall back to unauthenticated on a rejected token
or a spent token bucket, cache the list per channel for five minutes in the
main process (the refresh button forces a reload), classify 403 by the
rate-limit headers, and say when the limit resets.

Fixes #21898

* fix(updater): don't trip breaker for secondary rate limits

GitHub sends x-ratelimit-remaining: 0 on both primary and secondary
limits. Secondary limits carry Retry-After and shouldn't block all core
gh commands — only the primary limit should trip the shared breaker.

* Scope gh rate limits to execution environment

* Add build list cache hint to release channel settings

Inform users that build lists are cached for 5 minutes and they can
refresh to check for new builds immediately. This makes the cache
behavior visible and explains why a manual refresh is necessary to
bypass the cache.
2026-09-21 11:52:37 -07:00
Brennan Benson 33ba1ff3df fix(native-chat): stop a subagent's output speaking for the agent that spawned it (#21398)
* docs(attr-parent-label): record the attribution defect and its constraints

* docs(attr-parent-label): add reference findings and the feasibility fact

* docs(attr-parent-label): verify at source and decide the attribution mechanism

Re-baselined against origin/main (one unrelated commit; no drift in any cited
file). Confirmed the two unverified items at source, found a third reader with
the same defect and a fourth append path a naive fix would miss, and recorded
the producer-attribution decision with its field shape, migration behaviour,
wire category, tests and implementation order.

* fix(native-chat): stop a subagent's output speaking for the agent that spawned it

One journal is the durable record of one agent session, but a session that runs
subagents journals their rows into it too, with nothing on the row saying which
agent wrote it. Every "what is this agent doing right now" reader is a backward
scan bounded by markers only the root agent writes, so the window is guaranteed
to hold foreign rows and, while a subagent runs, the newest row in it is the
child's. The sidebar therefore showed a child's prose and a child's running tool
on the parent's row.

Attribute at the producer instead of guessing at the reader. The Claude
translator already parses `parent_tool_use_id` on every envelope and threw it
away; it now stamps `producedBySubagent` on every row that envelope produces,
including the streamed-text path, which persists from a callback with no
envelope in scope and takes the flag from the block identity registry that
already scopes itself on that id. The three status readers skip non-root rows
through one shared predicate. The transcript is deliberately left unscoped: it
shows every agent's output.

No schema version bump, no upcaster, no backfill. 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. Rows
written before the flag read as root, which reproduces today's behaviour for
that history exactly.

* docs(attr-parent-label): add the PR body for the producer-attribution change

* style(native-chat): apply formatter to the merge resolution

* fix(native-chat): preserve producer attribution in resolved appends

* chore: keep attribution review artifacts under docs

* chore: remove review artifacts
2026-09-21 11:31:43 -07:00
Brennan Benson 83a031c081 refactor(agent-status): delete two launch-config accessors left with no callers (#22032)
#21844 removed the Codex launch-argument attention suppressor, which was the
last production caller of two launch-config lookups. Both were left in place
for a follow-up; this is it.

`getAgentLaunchConfigForStatusMetadata` (renderer store) looked a launch config
up from a loose metadata bag. Its sibling `getAgentLaunchConfigForStatusEntry`
takes a real status entry and still serves the one live consumer, cold restore
resume startup. Deleting the metadata accessor also orphaned its
`getLaunchConfigForStatusMetadata` helper and the
`AgentLaunchConfigStatusMetadata` parameter type, so those go too.

`getAgentStatusLaunchConfigForPaneKey` (main runtime) returned a pane's launch
config behind a launch-token fence. Its two remaining references were
assertions in the launch-authority retirement test. They were a second view of
a state bit the test already pins: retirement nulls `pty.launchToken`, and the
surviving `verifyOrchestrationCompatibilityCaller` assertion fails when it
does not. Verified by ablation — disabling only the `launchToken` nulling
fails that assertion with the accessor already gone, so no coverage is lost.
Retirement never cleared `launchConfig` itself, so there was no second
property hiding in those assertions.

Test mocks that existed only to satisfy the removed store method are stripped;
the tests themselves are about other behaviour and stay.

No behaviour change.
2026-09-21 11:21:06 -07:00
Jinwoo Hong f07bf8544c feat(session-search): sort search results by newest, and break relevance ties by recency (#21863)
* feat(session-search): sort search results by newest, and break relevance ties by recency

Results were ordered by match score alone with the session id as the
tiebreak, so equally good matches came out in an arbitrary order and
nothing ever favoured recent work. The Sort menu now offers Most relevant
and Newest while the box has text; the engine already knew both orders and
the all-computers merge already honoured the newest one, so only the panel
had to ask. Under Most relevant, equal scores now go to the newer session.
The choice persists with the other view options, separately from the
list's own Last updated / Created sort.

* fix(session-search): label results by the order they are in

The header subtitle and the results group said "best matches" whichever
sort was chosen; under Newest they now say so. The panel's scope state and
its two context effects move to use-ai-vault-panel-scope.ts, which keeps
the panel under the line cap and gives that behaviour a name.

* feat(session-search): move search sort onto a results bar above the hits

Search mode gets a bar in the group header's place: the hit count on the
left, a ghost menu button on the right that names the current order and
opens the two-item radio group. The filter menu's Sort section keeps one
meaning again (Last updated / Created), the header subtitle stops
reporting sort, and search rows run flat with no group header.

* style(session-search): drop the icons from the results-bar sort menu and match its text size

* feat(session-search): one sort bar above the list in both modes

Filters stay behind the header filter icon; sort moves onto the bar
directly above the session list, in browse mode as well as search.
The bar is mode-agnostic: it takes a label, the selected value, a typed
option list, and a callback, and the panel configures it twice.

- rename AiVaultSearchResultsBar to AiVaultSessionListBar and generalize it
- add ai-vault-sort-options for the two option lists and their aria labels
- drop the Sort section from the filter menu and stop counting sort in the badge
- header subtitle now reads "Indexed history" in both modes

* feat(session-search): count sessions plainly and offer Show more when the scan fills its depth

* fix(session-search): step history depth 250 at a time and keep Show more visible while the rescan runs

* style(session-search): let the sort menu hug its two options

* fix(session-search): show more reads the depth its rows came from

The row inferred "a deeper rescan is running" from the selected depth minus one
page, which at the default depth is zero, so every foreground scan with at least
one session painted a disabled "Loading more sessions…" footer the scan had room
for.

The scan now publishes the depth it ran at beside its sessions, and the row
compares the two: it survives the rescan because that depth trails the selected
one until the deeper scan lands. Drops the stepping arithmetic and
nextAiVaultSessionLimit, and moves the row out of the menu file it was sharing.

* refactor(session-search): an untitled group is what hides a header

Search mode said "no group headers" twice, in two files, both keyed off the same
flag: an empty label in the filters hook and a hideGroupHeaders prop on the list.
The label is now the only fact. A null label means the group has no header of
its own, the list renders its rows flat, and the prop is gone.

The shared group type keeps its string label so the mobile sections that map it
are untouched; the nullable label is the renderer list's own type.

* refactor(session-search): plain labels, and a browse bar that can report zero

Three small simplifications around the list bar:

- The browse bar is guarded on the loaded history rather than the filtered rows,
  so "0 of 250 sessions" can actually appear when filters hide everything and
  the sort control stays reachable. Search keeps its own guard.
- The two count labels were components whose whole body was a ternary over
  translate; they are functions returning a string, and the bar's label prop is
  a string.
- The persistence guards stop being exported with no caller outside the file,
  and the search-sort guard reads the AI_VAULT_SEARCH_SORTS list instead of
  respelling the union.
2026-09-21 14:11:40 -04:00
Brennan Benson 663d670878 feat(agent-launch): deliver a launch prompt to a terminal agent (#21891)
`agent.launch` could hand its initial text to a structured session but not to
a terminal. The contract already anticipated the terminal half — the
`handed-to-terminal` arm has been declared in agent-launch-intent.ts since the
receipt was written and had zero producers — and the executor's own docstring
recorded the assumption behind the gap: that a terminal's paste belongs to the
pane owner. That assumption is what this overturns. The host owns the PTY, so
it can write into one whether or not any window is open on it, which is why
mobile and the CLI got an agent and no prompt.

A terminal takes its prompt one of two ways, and which one is not a
preference. `argv` exists so multi-line and special-character text reaches a
CLI as one argument rather than keystrokes, and it has no readiness race
because the text is in the process's arguments at exec time. So an agent whose
CLI accepts a prompt argument gets it on the launch command, and only a
`stdin-after-start` agent — plus any reused terminal, whose process started
before the launch existed — is written to as a bracketed paste.

That fork is asked once. `agentPromptRidesLaunchCommand` is derived from the
same injection table `buildAgentStartupPlan` branches on, and
tui-agent-prompt-transport.test.ts pins the two against each other for all 37
agents, so adding an agent or changing its mode fails loudly instead of
silently dropping that agent's prompt.

Reused rather than rebuilt: `sendTerminalAgentPrompt` is the runtime's one
agent-prompt writer (bracketed paste, per-PTY serialization, lifecycle
generation pinning, per-agent submit timing, and local/WSL/SSH routing), gated
by `waitForTerminal('tui-idle')` — the same pair orchestration's worker
dispatch already delivers a preamble through. The agent-first create path
needed no new mechanism at all: `startupPrompt` already flows to
`buildWorktreeStartupForAgent`, and the launch had simply been stripping it as
a reserved field without re-supplying its own.

Receipts stay consequences of the act they name. `handed-to-terminal` is
reported only from a launch command that carried the text or a PTY write that
returned; everything unproven under-claims as `not-delivered`. No fourth arm.
The one inversion is a stalled submission, which the verifier raises after the
write: that is reported as delivered, because a resend would paste the whole
prompt a second time into an agent already working on it.

A prompt the launch command cannot carry is refused at the terminal-create
resolver rather than dropped, since that path returns options and has no PTY
to fall back to.

`delivery: 'draft'` remains `not-delivered` for both surfaces. The host could
paste a terminal draft without submitting it, but it cannot observe that the
composer accepted it, so a receipt claiming delivery would be a guess.

No call site is migrated, nothing is added to the wire, and placement and tab
creation are untouched.
2026-09-21 09:19:34 -07:00
Neil 2a785274ad fix(remote-runtime): stop an unhydrated host graph from reading as a closed terminal (#21967)
A remote pane asks the host two different questions about its terminal, and
they consult different amounts of state. `session.tabs.list` hydrates first —
`listMobileSessionTabs` runs the workspace-session hydrate with
`allowAttachedWindow: true`, then restores live paired-renderer terminals.
`session.tabs.activate` hydrates with no options, which is a no-op whenever an
authoritative window exists, so it answers `tab_not_found` from state the host
never filled in. The client treated that as removal evidence and surfaced
"Remote terminal was closed." over a terminal the same host was still
reporting connected and writable.

Activation's absence answer is now non-authoritative: the bounded inventory
poll below adjudicates. A surviving sibling leaf with this leaf gone still
returns removal evidence and still surfaces the toast; nothing conclusive
inside the window stays unknown liveness, which parks a retry instead of
asserting closure. `null` leaves both return unions.

Fixes #21852
2026-09-21 03:24:52 -07:00
Neil 4feaaf5c5c feat(terminal): configure interactive Unix shell arguments (#21904)
* feat(terminal): configure interactive Unix shell args

* fix(settings): clarify Unix shell argument defaults

* fix(settings): improve Unix shell argument guidance

* fix(settings): simplify shell argument guidance

* fix(settings): clarify empty shell args

* fix(settings): explain empty shell args

* feat(settings): make shell argument modes explicit

* fix(settings): keep no args inside custom mode

* fix(terminal): apply configured shell args on the renderer spawn path

The renderer's pty:spawn handler builds options in ipc/spawn-options, not
the runtime controller, so the configured profile never reached a terminal
pane. The local launch plan also dropped the args whenever shellOverride
was set -- which the spawn path always fills from terminalDefaultShell.

Both spawn paths now share one resolver.

* chore(i18n): allowlist the new terminal shell argument strings

Matches how the sibling Terminal shell settings strings are already handled.
2026-09-21 01:35:58 -07:00
Neil ac4d6b407a fix(devin): skip workspace trust for Orca launches (#21925)
* fix(devin): skip workspace trust in yolo launches

* fix(devin): migrate existing workspace trust defaults

* fix(devin): persist migrated launch arguments

* fix(i18n): include required source control stop label
2026-09-21 00:43:27 -07:00
Jinjing 1b77838d1c Consolidate source control tooltips to eliminate redundant hover text (#21733)
* refactor(source-control): consolidate tooltips to avoid duplicates

- Remove native title attributes from buttons that also render Radix tooltips
- Introduce PrimaryActionTooltip wrapper that decides whether to show a tooltip based on context
- Hide pure repeats: enabled Stage All and Create PR have no tooltip since the label already states the action
- Show tooltips only when they add information: disabled reasons, commit shortcut, and remote counts

* Show Create PR intent tooltip to explain the prepare step

The Create PR intent label doesn't convey that clicking it stages,
commits, and pushes before opening the PR dialog. Keep the tooltip
to surface this multi-step operation that users might not expect.
2026-09-21 00:05:13 -07:00
Brennan Benson afb618f2b3 refactor(agent-status): drop two superseded Codex attention workarounds (#21844)
* refactor(agent-status): drop two superseded Codex attention workarounds

Codex fires its PermissionRequest hook as decider #1, before its own
auto-reviewer and before the user, so the event never meant "a human is
blocked". #21389 fixed that at the source: the execution host reads the
turn's approvals_reviewer from the rollout at write time and keeps a
reviewer-owned approval in `working`.

Two older reader-side workarounds for the same bug are now redundant.

The launch-argument suppressor guessed auto-approve mode by string-matching
the launch args, then dropped the status row in the reader. It only matched
Codex's bypass flag, and under that flag Codex's approval policy is `Never`,
which takes the Skip path and fires no PermissionRequest at all. When the
user turns on "Approve for me" inside a live session the args never change,
so it never fired for the actually-reported case either.

The Codex-only 1.5s notification quiet window could not do its job: measured
auto-reviews take 3-20s and a human can answer in under a second, so no
fixed constant separates them. Its deferred callback also re-checked
liveness and returned without notifying, so a genuine prompt whose pane went
non-live inside the window was dropped rather than delayed. Codex now
notifies synchronously like every other agent.

Also types the coordinator's completion state from the controller's exported
CompletionState instead of asserting each field, which the changed-lines
casting gate required once those lines moved.

* fix(agent-status): settle transient process-exit evidence
2026-09-20 23:32:00 -07:00
Neil b2fe56def9 fix(worktree): recognise the Windows profile through WSL's drvfs view (#20051)
* fix(worktree): recognise the Windows profile through WSL's drvfs view

`/mnt/<letter>` under a WSL UNC alias is the distro's drvfs mount of a Windows
volume, so `\\wsl.localhost\Ubuntu\mnt\c\Users\bob` is `C:\Users\bob` wearing a
Linux spelling. The Windows-profile rule excludes every WSL UNC path by design
(the aliases normally front a Linux filesystem) and the POSIX shapes never match
a `/mnt/...` tail, so that path fell through both and read back as deletable.

The spelling is producible by the product: `resolveWslRepoWorktreeBasePath` maps a
`/mnt/c/...` worktree base against a WSL repo into exactly this UNC form, and
`getWslFilesystemBoundaryDistro` already treats it as the drvfs crossing.

A drvfs tail now takes the Windows rule on its drive form, via the existing
`toWindowsWslDrivePath`. Scoped to the UNC branch, where `parseWslUncPath` has
proven the path is a WSL alias — a plain Linux host's `/mnt/c/...` is untouched.
The lowercase-only `/mnt` match is deliberate: `/MNT` is an ordinary
case-sensitive Linux directory, never the automount.

* fix(worktree): refuse the drvfs volume root and the automount under a WSL UNC alias

`\\wsl.localhost\Ubuntu\mnt\c` is the whole C: volume and `\\wsl.localhost\Ubuntu\mnt`
holds every drvfs volume. Neither is caught by the root check in
`isDangerousWorktreeRemovalPath` (their win32 root is the distro share) nor by the
Windows-profile rule on the drive form (`C:\` is not `C:\Users`), so both read as
deletable. Measured on a Windows 11 host with WSL2: `rm -rf` inside the distro on the
`/mnt/c` spelling deletes on the Windows drive.
2026-09-20 23:26:51 -07:00
Brennan Benson 4da3a95d50 fix(native-chat): scope a tool row's hover reveal to that row (#21918)
Hovering one tool call in a chat turn revealed the expand chevron on every
other row in the same message at once, so the whole message lit up and nothing
said which row the click would open.

The rows were reading a hover they do not own. Tailwind's unnamed
`group-hover:` is not nearest-ancestor scoped — it compiles to
`:is(:where(.group):hover *)`, which matches a hover on ANY `.group` ancestor.
`NativeChatMessageRow` wraps the whole assistant message in a bare `group` for
its own copy/timestamp reveal, so every collapsible row nested inside it
answered to that wrapper as well as to itself.

Each row now names its own group — `group/tool-line`, `group/tool-run`,
`group/subagent-run`, `group/diff-card` — which compiles to
`:is(:where(.group\/tool-line):hover *)` and reaches that row alone. The
message-row reveal is left bare on purpose: its copy button and timestamp are
meant to answer to a hover anywhere in the message.

`NativeChatDiffCard` is included for the same defect, not as extra scope: its
verb label was brightening on any hover in the message.
2026-09-20 23:26:29 -07:00
Neil 8cf1c8594e docs(opencode2): clarify quick command delivery (#21929) 2026-09-20 23:22:27 -07:00
Neil 70c4f20466 fix(opencode2): auto-submit quick command prompts
OpenCode2 quick commands now submit through the ready-state delivery path. Focused regression coverage and full CI pass.
2026-09-20 23:08:36 -07:00
Neil 98299d879b fix(terminal): persist a parked remote pane's scrollback across a hard restart (#21295) (#21367)
* fix(terminal): route a parked pane's scrollback patch to the remote host's partition

A park capture changes only terminalLayoutsByTabId, so its debounced session
patch carries no tabsByWorktree. splitWorkspaceSessionByHost built its
tab->worktree index from the patch alone, resolved nothing, and routed every
layout to the 'local' partition, where main's pruneLocalTerminalScrollbackBuffers
strips scrollback it cannot attribute to a remote worktree. The remote host's
runtime:<id> partition never received the capture, so anything parked since the
last clean checkpoint was lost on a crash, SIGKILL, or a forced kill during an
app update (#21295).

Route tab-keyed patch fields with the renderer's live tab catalogs as a fallback
when the payload names no tab rows. Payload rows still win, so full-payload
writes are byte-identical. Once routed to runtime:<id>, main merges the
partition's own prior tabsByWorktree and the prune preserves.

Proven by tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts: a
hard kill (no checkpoint) then relaunch, asserting the capture is in the remote
host's partition on disk. Mutation: reverting the routing fix turns that
assertion red and fails the 3 catalog-dependent unit routing tests.

(cherry picked from commit 58a344c1d4)

* test(terminal): read both scrollback homes in the restart spec, and ratchet the resolver to the cap's home list

The restart spec read only buffersByLeafId, but the ordinary park now writes
localOnlyScrollbackByTabId, so its own proof reported a false zero and both tests failed for the
wrong reason. Both readers now go through resolveLeafScrollbackBuffers: the on-disk reader calls
it directly (it is a pure function), and the store reader — which runs inside page.evaluate —
reaches it through a new window.__terminalParkingDebug.resolveLeafScrollback(tabId) handle.

resolveTabScrollbackBuffers is typed off TERMINAL_SCROLLBACK_SESSION_HOMES and its unit test
enumerates that constant, so adding a third home fails to compile and fails a test until the
resolver reads it — the 'no consumer reads a home directly' invariant becomes enforceable.

The clean-quit control no longer asserts tokenAfterReveal (measured true, true, false on identical
product code; the live host can serve the reveal from its own tail). It keeps the five
deterministic fields and logs the reveal; the hard-kill test still asserts it, because there the
host is forced unavailable and the reveal must come from the client copy.

(cherry picked from commit 0cd3db1489)

* docs(persistence): pin why the local-only scrollback home stays outside full normalization

The two scrollback homes look symmetric (TERMINAL_SCROLLBACK_SESSION_HOMES), so the missing key
reads as an oversight. It is load-bearing: adding it would route the field through the fail-closed
strip and reintroduce the loss this branch fixes. The renderer prunes it with attribution before
the patch is sent, so the cap still holds without main as a second line.

(cherry picked from commit 1092e35b0a)
2026-09-20 22:57:33 -07:00
Neil f492054bf0 fix(runtime): an outage is not a handle-gap verdict (#20059)
* fix(runtime): an outage is not a handle-gap verdict

The per-pane handle-gap wait releases at a 15s deadline and records that
expiry as a verdict, which authorises the sleeping-agent resume. The
connection generation was the only thing voiding that verdict, and a plain
disconnect never advances it — runtime-status.ts advances on the reconnect,
under a new runtime id. So a network drop mid-turn expired the wait with a
generation that still matched, and the replay forked a second `--resume`
onto the transcript the host was still writing: #19735 through the
disconnect door.

Suppress the verdict while the client positively knows it is out of contact,
reusing the shared runtime-host connection derivation. The waiter still
releases and re-parks, so contact returning gets a full fresh budget and the
pane is still decided on real silence.

Not redundant with the landed-handle drain that follows this commit, nor with
the read-time pane identity from adv2-skew (cdafc90d8f). Mutation on the
composed tree gives three disjoint kills: dropping this guard fails only "does
not turn an outage into a verdict"; dropping the landed-handle drain fails only
the two landed-handle cases; forcing this guard always-true fails 16 across every
suite. Three guards, three holes.

* fix(runtime): an outage inside the budget is not a handle-gap verdict either

The contact check at the deadline is a snapshot of now. An outage that began
and ended inside one wait, on the same runtime, leaves contact restored and the
generation untouched, so the deadline recorded a verdict after milliseconds of
real contact. Capture hostContactEpoch at park time and refuse the verdict when
it moved; the waiter still releases and re-parks for a fresh budget.

Also pins the transport-down snapshot shape (reads 'reconnecting'), which is
what main's status channel actually publishes for a dropped link and was the
one arm of the guard no test exercised.
2026-09-20 22:56:43 -07:00
Neil 4feca6baa1 Keep new worktree dialog actions visible while scrolling (#21915)
* Keep new worktree dialog actions outside scrolling content

* Trigger missing PR checks
2026-09-20 22:54:03 -07:00
SahilZ0810andNeil 9324bb8137 fix(editor): preserve Markdown preview when following wiki links (#19790)
* fix(editor): preserve preview when following wiki document links

* test(editor): avoid cast in markdown navigation fixture

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-20 22:52:45 -07:00
Neil 2872c3fccc fix(claude): prefill continuation context for manual submission (#21912)
* fix(claude): prefill continuation context for manual submission

* fix(agents): force draft paste when inline prefill falls back
2026-09-20 22:45:05 -07:00
Jinwoo Hong 5d13a70ea3 fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9)

The page decides an in-page hop from `init.pageRoutes`, which says which patterns
this shell would render and nothing about what each one costs. So a push kept
local on the strength of the pattern alone runs the target under the opener's
grants — which is how the tasks page is reached from the wide-layout sidebar
without `native.clipboard.write`, and why its copy actions refuse silently.

`init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs,
from the manifest the shell already holds. Optional in both directions: an older
shell omits it and an older page ignores it, and a page that receives none keeps
today's rule. No new frame kind, no cap change, no protocol bump.

The grammar is the manifest's, imported rather than restated
(`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the
bundle could not have declared cannot reach the page through this field either.
The host validates the pairs before it builds the frame and refuses the session
when they fail, for the reason it already refuses a malformed route: an `init`
the page would reject whole is worse than no session at all.

Two files were at their line ceiling and are split rather than bumped. The pairs
schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope
and the host, so it belonged in one place anyway. In the session reducer the
three sites that each spelled out "patterns, their grants, this route's grants"
become one `routeViewOf`; that is a net reduction and removes the fourth spelling
before it is written.

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

* fix(mobile): keep a hop local only when the session's grants cover it

The rule the page was using is "the shell would render this pattern", and that is
not the question. Grants are resolved once, from the route the shell opened, so a
push kept local runs the target under the opener's list. On a wide layout the
sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this
seam, so from the worktree list, agent history or the files pages the tasks page
ran without `native.clipboard.write` and its copy actions refused with nothing on
screen to say why.

`servedHere` now means served here *and* covered: the target's declared grants
must be a subset of this session's. An uncovered page route is handed to the
shell exactly like a non-page route, and the shell opens it as its own session
with its own grants — which is the mechanism that already exists, rather than a
new one.

Three answers, not two, because an absent field is not an empty one. A shell that
sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older
shell has to keep working. A target the shell lists but names no entry for is
*not* covered — the page cannot justify that hop, so it hands it over rather than
guessing in the direction that loses grants.

This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is
covered by this rule and stays local, and the rule scales to the sidebar, which
reaches every route and which no pairwise list can keep up with.

Red first on the two cases only the new rule answers; the other four are the
regression guards and passed before and after. Two whole-session assertions
gained `pageRouteGrants: null`, which is what the reader now returns.

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

* test(mobile): prove the sidebar hop in a browser, under the session's own grants

The unit tests pin the decision; only a browser shows the control exists, is
reachable at the viewport where the sidebar renders, and that the document does
not move when the hop is handed over.

Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted
when a caller names none, because an absent field is not an empty one and the
page reads the difference).

- Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one
  `navigate` notify, the document stays on the worktree list, and **no new chunk
  is fetched** — which is what says the page did not quietly render tasks under
  the wrong grants.
- Wide, same tap with the grant added: no notify, the document moves to `/tasks`.
  Without this the first case would pass on a page that simply never navigates.
- Wide, shell sending no pairs at all: the old behaviour, local. An older shell
  must not start handing every hop over on a field nobody sent.
- Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the
  sidebar only on a wide layout, and only that header branch labels its Accounts
  and Tasks controls; the narrow header's are unlabelled pressables. So the hop
  does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is
  the honest assertion. That unlabelled narrow header is a real accessibility gap
  and is not this lane's to fix.

Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks.

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

* test(mobile): census the in-page hops a session's grants cannot cover

The rule landed in the commit before this one decides each hop; this says which
hops those are, so a route's grants growing — or a new push between two page
routes — shows up here rather than as a verb that silently refuses on a device.

Openers are every page route, not the one that happens to push. On a wide layout
`app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and
its header pushes tasks, which is exactly why a pairwise pin is the wrong shape:
the sidebar reaches everything, so the census has to be the cross product of what
the manifest declares against what the source actually builds.

Targets come from the hrefs the app builds, read out of `mobile/src` and
`mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned
and a hop someone adds is. A presence case asserts the sidebar's tasks push is
among them, because a census that stopped finding hops would go quietly green.

Two hops are pinned as handed off today, both into tasks, which is the only route
declaring more than `navigate` and `storage`. A third case asserts the other half
of the rule on the manifest: a target asking for no more than its opener stays in
the document.

Checked that it discriminates rather than assuming: widening the worktree list's
grants to cover tasks fails the pin, and restoring them passes it.

**No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to
be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has
three routes and no `files` entry, so there is nothing to remove. When C3.1 lands,
its pin is this census's to subsume.

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

* fix(mobile): drop an unused import from the hop census

`statSync` was imported and never used; `oxlint` fails it. My error: I committed
the census on a green test run without waiting for lint, the same order mistake I
made earlier in this lane. Fixed forward rather than amended, because the lane
forbids rewriting a commit that exists.

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

* test(mobile): fold C3.1's pairwise grant pin into the hop census

C3.1 landed while this branch was open, and it brought the case this lane
generalises: the explorer pushes to its own preview, that push stays in the
document, so the preview runs under the explorer's grants. Its pin asserted that
one pair by name.

The census now covers it as a consequence rather than a rule. With the files
routes in the manifest the cross product finds six more hops the session cannot
cover — the sidebar into files from the worktree list and from agent history, and
both files routes into tasks — and it does **not** find explorer → preview,
because the preview declares no more than the explorer. That absence is the
pairwise pin, derived.

So the pairwise block is deleted, with its import. The rest of that file stays:
its external-link seam checks and its clipboard-absence control are about what
the files closure contains, which this census says nothing about.

Checked the extended census still discriminates: granting the explorer
`native.clipboard.write` fails the pin, restoring it passes.

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

* test(mobile): prove the sidebar hop from a files route, not only the worktree list

The defect is not "the worktree list pushes tasks". On a wide layout the sidebar
renders beside every `/h` route, so the same hop exists from the files explorer,
whose session carries `externalLink` but not `native.clipboard.write`. One opener
proving the rule would have left the general case to inference, which is the
inference C3.1's pairwise pin already made once.

Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's
Tasks control posts exactly one `navigate` notify, the document stays on the
files route, and no new chunk is fetched.

The harness helper now takes the route and the text to wait for, so a case can
open on something other than the worktree list without a second copy of it.

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

* fix(mobile): make the render helper wait on the text its caller named

The `awaitText` parameter I added in the commit before this one was never wired
into the wait, so it was dead and `oxlint` failed it. The case still passed,
because the files route renders the host name in its sidebar and that is what the
helper was still waiting on — which is exactly the kind of accident a dead
parameter hides.

Third time in this lane I have committed on a green test run before lint
finished. Fixed forward, not amended.

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

* fix(mobile): carry route grants through the download path

`onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the
new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so
every first install and every OTA update reached `ready` with the default or the
previous generation's pairs. The page then read each target as listed-with-no-
entry and handed off every in-page hop.

`routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls,
to keep the reducer under its line cap without a bump; its stale neighbouring
comment, which described a filter that moved into it, goes.

Red first: the cold-cache and generation-change cases failed, the cached-hit case
already passed.

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

* test(mobile): derive census targets from navigation call sites

The reachability filter was inert. Harvesting every `/h/${…}` template caught the
five screens that declare their own mount pathname, two `pathname ===`
comparisons and the route template types, so every declared route was reachable
through its own mount: the pinned table was the all-pairs one, eight hops with
the filter and eight without.

Targets now come from the arguments of `router`/`navigation` `push`, `replace`
and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are
excluded by construction because they are not navigation arguments. Two real
hops are not written as a literal, so a local binding or a call is followed one
step to the function that returns the pathname: the files explorer is pushed as
`{ pathname: descriptor.pathname }` and the preview as
`push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read
is returned rather than dropped.

Derived patterns go from 11 to 10; the pinned table stays at eight because all
five page routes are genuinely pushed to. What changes is that the filter now
discriminates: deleting the header's two tasks pushes reds the presence case and
drops the four `-> tasks` rows from the pin, where the old derivation stayed
green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the
pathname. A push added at a real call site appears in the set.

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

* test(mobile): restore the preview-declares-something guard

The pairwise pin this case replaced asserted the preview declares at least one
grant before asserting the explorer covers them all; without it two empty lists
satisfy the subset check and a route that lost its grants passes.

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

* docs(mobile): describe the route list under the handoff rule

Two passages described the world before this PR: the explorer's note said the
census pins its pair with the preview, and a closing paragraph left the sidebar's
tasks hop open for a later PR. This is that PR. Covering the preview now buys the
in-document hop rather than making it correct, an uncovered target is handed to
the shell and reopened under its own grants, and the census reads the explorer to
preview relation off this list rather than pinning it by name.

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

* test(mobile): mirror the manifest's tasks grants in both fixtures

CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`,
`storage`, `native.clipboard.write` while the manifest also declares
`externalLink`, so no covered-session case ever required it.

Both now mirror the manifest's four, and the covered sessions hold them. That
alone does not make an `externalLink`-blind rule fail, since those sessions hold
every grant either way, so the unit suite gains the case that does: a session
holding the clipboard but not `externalLink` must still hand the hop off.
Mutating the rule to treat `externalLink` as always held reds that one case and
no other.

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

* test(mobile): make a stalled hop name its own cause

Both waits for the hop to land read as a bare 30 s timeout when it does not. The
CI failure that sent this file back was a `TypeError` inside React Navigation
that blanked the document, and it was invisible here because the error
assertions run after a wait that never returns.

The wait now throws with the page's own account: the pathname it stayed on, the
collected page and console errors, the `navigate` notifies posted, the first 300
characters of the body, and every `.js` response since the click with its status.
The response listener records every script answer rather than only the 200s, so a
chunk the navigation waits on can be seen failing; the 200-only list the
no-new-chunk assertions read is unchanged, as is everything the five cases
assert. Kept in this file because no other render file waits on the pathname
moving.

Proved by mutating the rule to hand every hop off: the covered case fails naming
the pathname it stayed on, an empty error list, the notify it posted and no
scripts since the click — which is the handoff signature, distinct from the
crash signature CI saw.

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

* test(mobile): aim the narrow hop at the control C2.10 named

The narrow case asserted the absence of a labelled Tasks control, which
was true only because the narrow toolbar carried no accessibility props.
C2.10 gave it the wide sibling's role and label, so the assertion was
red on the merge and, worse, the rule this file is about went unproven
on the branch the phone actually presses.

It taps that control now: at 390 px there is exactly one, and the tap
posts exactly one navigate notify for the tasks route while the document
stays on the worktree list and fetches no new chunk. Red first against
the merged header (count 1, expected 0); with the session given
native.clipboard.write the hop goes local and the case reds, which is
what says the assertions discriminate.

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

* docs(mobile): drop the handoff predicate's contradicted one-liner

The pre-C2.9 summary said the answer is whether this document renders
the target, which is exactly the claim the block comment below it
replaced: the predicate now also requires the target's grants to be
covered. Two doc comments on one declaration, the first of them wrong.

Comment only; the 35 handoff cases are unchanged and green.

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

* test(mobile): assert which field a route refusal blames

The host builds `pageRouteGrants: <issue>` so a refusal says which of the
two checked inputs failed, and nothing read it: the case counted
refusals, so a host that reported the route's own verdict for a malformed
pair would have stayed green while sending whoever reads the refusal to a
pathname that was never the problem.

The case pins the prefix, a non-empty issue behind it, and that the
diagnostic and the callback carry the same string. The control is an
opener that fails the other way: a malformed route reports its own issue
and does not take this prefix, without which the pin would hold on any
reason at all.

Red first with the field branch dropped from the reason: the prefix
assertion fails and the control stays green.

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

* refactor(mobile): stop exporting the route filter the reducer stopped calling

`implementedPageRouteEntries` and `implementedPageRoutes` were the
reducer's two ways in before it moved to `routeViewOf`. The entries form
had no caller anywhere afterwards and the patterns form had only this
test, so the module's public surface advertised two functions no product
code reaches. Both are module-local now; the surface is
`matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`,
`routeViewOf` and the grant list.

The test reads the same list through `routeViewOf(...).pageRoutes`, which
is the reducer's own view of it, so no assertion changed and no export is
kept for a test.

Red first: with both un-exported and the test untouched, seven cases fail
with `implementedPageRoutes is not a function`; routed through the view
all nineteen pass. Still discriminating, as a control: with the grant
filter dropped from the entries helper, four of them fail.

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

* test(mobile): route the merged haptics cases through the policy view

PR E's two haptics cases arrived with the merge calling
`implementedPageRoutes`, which this branch had already made module-local,
so the merged file was red with `implementedPageRoutes is not defined`
on both of them. They read the same list through `pageRoutesOf`, the view
the rest of the file already uses, so neither assertion changes.

PR E's paragraph named that function for the filter it describes; the
filter now sits in the entries helper the view is built on, so the
sentence says that instead of naming a function the reader cannot see.

Red: the two cases above on the merge. Green: all 21, PR E's two included.

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

* test(mobile): mirror the haptics token in every handoff fixture

PR E put `haptics` on all five manifest routes, and these fixtures still
carried the pre-E grant lists: tasks with four grants where the manifest
now declares five. A fixture that is short the same token on both sides
of the subset check agrees with the rule by accident, and would have gone
on agreeing after the token stopped being universal.

The pairs mirror the manifest now, and each session carries what its
opener route would actually be granted, since the host narrows a route's
declared grants to what the shell implements and the shell implements the
token.

Red first, with the token added to the pairs alone: the two covered-hop
cases flip to handed-off, `stays in this document when the session
already covers the target` and `keeps the hop in the document when the
session covers tasks`. Green once the sessions carry it, 35 and 5.

The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself.
Measured there, all 5 routes declare the token and it is the missing
grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the
rule still reads only `pageRouteGrants`.

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

* test(config): count C2.9's two bridge modules in the session route closure

#21908 recorded this pin at 4,324 for the haptics notify module. C2.9
adds two more that the same closure reaches: the page-route-grants schema
and the manifest contract whose grant grammar it imports rather than
restates, both pulled in by `bridge-envelope.ts`, which the page reads to
parse `init`.

Named in the docstring beside #21908's sentence rather than folded into
its number, because the three modules arrived from two PRs and a single
count with one reason invites the next author to assume the rest.

Red first against 4,324: expected 4,326. Measured on this head, not
inferred -- a control worktree at pristine main gives 4,324, so the two
are this branch's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 01:29:02 -04:00
Neil c74ba15f31 fix(claude): stream the provider history window (#21742)
* fix(claude): stream the provider history window

An oversized Claude transcript made restart reconciliation unresolvable:
readClaudeProviderHistoryWindow buffered the whole project JSONL, so a file
past the 16 MiB bound returned an inconsistent boundary — the one answer the
reconciler can never act on — and a file just under it still went resident.

The window now reuses the streaming primitives from #21024 instead of a
whole-file read. Two bounded passes run over ONE pinned descriptor and size:
the graph pass builds the branch proof (uuid/parentUuid only), and the replay
pass hands back the first record per chain uuid, fingerprinted on the spot. A
repair appended mid-read re-runs BOTH passes at the grown size, so the replay
can never read bytes the proof did not vouch for. The 16 MiB bound survives as
a per-record framing limit, which is the only remaining way the source could
become resident.

claude-transcript-branch-proof.ts is split at its real seam to stay under
max-lines: claude-transcript-branch-graph.ts is what the rows mean as a graph,
and the proof file is which bytes the graph gets to see.

Peak heap over a 252 MiB transcript: 542 MiB whole-file, 53 MiB streaming —
and flat at 53 MiB for a 63 MiB transcript, where whole-file took 136 MiB.

* fix(claude): fail closed when the ancestry walk misses the anchor

The source-budget anchor test filtered on `"latest"`, which also removed the
last-prompt marker. The transcript was unprovable, so the empty window and
single pass it asserted came from an INCONSISTENT verdict, not from the
leaf-equals-anchor path. Filter the record only and assert the boundary.

`ancestryChain` returned [] both for "the leaf IS the anchor" and for a walk
that fell off the graph. The first means nothing followed the anchor; the
second means we never looked. Throw on the second, so the window reports an
inconsistent boundary rather than non-delivery.
2026-09-20 22:12:23 -07:00
Neil 253f0e3946 Fix Antigravity source-control model discovery and retired defaults (#21606)
* fix(antigravity): discover current source-control models and use CLI defaults

* fix(antigravity): gate configured models on remote runtime support

* fix(runtime): forward default TUI agent for remote git generation

* test(runtime): cover inherited agent forwarding
2026-09-20 22:12:02 -07:00
Jinjing 00da5fd556 test(worktrees): add comprehensive nested lineage coverage (#21903)
- Add 10 test cases for nested worktree rendering and collapse behavior
- Handle edge cases: cycles, uneven siblings, multiple depth levels
- Extract stopNestedWorktreeCardBubble to shared header-event-guards module
2026-09-20 22:11:08 -07:00
SahilZ0810 ffb79c71e0 fix(editor): open plain details blocks in rich markdown mode (#19784)
* fix(editor): allow plain details blocks in rich markdown mode

* fix(editor): preserve case-sensitive details class values
2026-09-20 22:05:30 -07:00
Neil 27a0889dcf test(relay): account for OpenCode marker in OMP launch environment (#21907) 2026-09-20 21:41:58 -07:00
Neil 35005fb65c fix(pi): keep panes working while async subagents run (#21882)
* fix(pi): wait for async subagents before settling pane

* fix(pi): handle subagent event aliases and reloads

* test(pi): assert lifecycle listener cardinality
2026-09-20 21:38:38 -07:00
Jinwoo Hong ba7583244b fix(editor): persist PDF zoom across tabs and restarts (#21879)
* fix(editor): persist PDF zoom preferences

* fix(pdf): avoid path-only zoom persistence
2026-09-21 00:24:20 -04:00
Neil 7b97551acf fix(opencode): isolate v1/v2 plugins and preserve WSL config (#21900)
* fix(opencode): include cache read and write usage totals

* fix(opencode): satisfy aggregate query safety checks

* chore(i18n): refresh runtime English catalog

* fix(opencode): isolate plugin variants and preserve WSL config
2026-09-20 21:09:23 -07:00
Jinwoo Hong 8a3a119052 fix(i18n): regenerate the runtime catalog and localize the cookie example from #21462 (#21889)
* fix(i18n): regenerate the runtime-required English catalog for the AccountsPane strings

#21462 (7a6d10064e) added eight en.json entries without re-running the
generator, so `verify:localization-runtime-catalog` fails on main and on
every PR's merge ref. Generated with `--fix`; no hand edits.

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

* fix(i18n): localize the cookie-header example in the OpenCode Go setting

The same commit (#21462) left one `<code>` example as raw JSX text, so
`verify:localization-coverage` fails on main once the runtime catalog
passes. The text already has a key (its placeholder twin uses
`auto.components.settings.AccountsPane.37b4b4a3f7`); reuse it. Control:
with this edit reverted the check names exactly this site, with it
restored the check passes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:07:50 -04:00
Neil 87a22db25a fix(opencode): include cache read and write usage totals (#21886)
* fix(opencode): include cache read and write usage totals

* fix(opencode): satisfy aggregate query safety checks

* chore(i18n): refresh runtime English catalog
2026-09-20 21:02:24 -07:00
Jinwoo Hong 58a80d996b test(runtime): expect the agent's own submit delay in the PTY timing policy case (#21874)
#21665 gave antigravity a per-line settle before Enter, so the delay the
test computed from bytes alone is 45 ms short of what the runtime waits.
Under fake timers that leaves the submit pending until the real 30 s
timeout, which is what every PR's node shard 8/8 has been failing on
since it landed. The case now derives the expected delay from the agent's
policy, so a future per-agent term moves the expectation with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 23:42:00 -04:00
Wooseong Kim 7a6d10064e fix: read OpenCode Go usage from the console API (#21462)
* fix: read OpenCode Go usage from the console API

The workspace HTML page now 302s to console login. Fetch
/console/api/go/status with x-org-id, map JSON meters into the
existing windows, and keep __Host-console_session on the closed
cookie allowlist.

Fixes #21420

* fix: tell users to paste the OpenCode console session cookie

The Go status API is authed by __Host-console_session. Settings still
told people to paste auth only, which 401s. Ask for the full Cookie
header; auth remains enough for workspace discovery.
2026-09-20 20:12:08 -07:00
Pablo Werlangandorca-agent 646fa3645f fix(opencode): attribute shared-server sessions to their panes (#21577)
* docs: allow-list opencode tool-readout follow-up note

* fix(opencode): attribute shared-server sessions to their panes

The v2 shared server stamps every hook post with its own frozen pane,
so all panes' status lands on the starter pane (#21359).

- shared: session->pane registry plus ingest-time envelope rewrite;
  bound sessions resolve to their real pane, tab and live launch token
  before disposition, unbound sessions keep the stamped identity.
- main: binder poll (SQLite session store, PTY-registry pane snapshots,
  argv-aware client sweep) with directory-containment plus
  client-lifetime correlation; 60s loop plus debounced SessionStart kick,
  wired into the hook server lifecycle.

* fix(opencode): newest-wins pane dedupe, macOS private/tmp normalization

Live verification against the dev instance found two binder gaps: remint
rows for one pane counted as an ambiguous tie, and /tmp vs /private/tmp
spellings never met on macOS.

* fix(opencode): review fixes — newest-wins worktree, drop dead constant

- applyBinderOwnerships now overwrites per-pane worktree, matching the
  round's newest-wins pane dedupe; a remint's live row wins over a stale
  row (pinned by test).
- remove the unused OPENCODE_CLIENT_PRE_CREATE_WINDOW_MS export and the
  nowMs residue from clientCouldCreate.
- give the per-pane launch-token cache its own named cap constant.

* fix(opencode): address thread review — cursor, native table, tokens, lifecycle

- composite (time_created, id) store cursor advanced past handled rows
  only, so same-millisecond pagination and full unbound maps no longer
  drop sessions silently.
- Windows sweep reads the native process table instead of forking
  powershell.exe; quote-aware argv parsing on both platforms.
- directory keys via normalizeRuntimePathForComparison (Windows
  case-fold, POSIX backslash literals) plus narrow macOS /tmp|/var|/etc
  aliases and lexical dot-segment resolution.
- bound sessions always take the stored pane token (never the frozen
  stamp); token tracking runs after resolution.
- binder generation guard discards post-stop rounds; first round runs
  immediately at loop start.
- unbind/move use exact pane-key match; pane launch-token cache gets its
  own cap constant.
- move the tool-readout note out of this PR for its own branch.

* fix(opencode): second review round — executable field, worktree scope, round lifecycle

- POSIX sweep reads comm= alongside args= and classifies on the
  kernel executable name, so unquoted install paths with spaces no
  longer split argv[0] and reject the client; Windows rows carry the
  native table name. Degrades to argv[0] when comm is unavailable.
- bound sessions take only the binding's worktree (never the stamped
  pane's), so a worktree-less binding cannot file a row under the
  wrong worktree.
- the binder generation is captured before the round body and the
  running flag clears only for the current generation, so an obsolete
  post-stop round cannot admit an overlapping round.

---------

Co-authored-by: orca-agent <orca-agent@local>
2026-09-20 20:06:55 -07:00
Wooseong KimandNeil 30f2bc60f9 fix(antigravity): recognize non-Gemini tui-idle prompts (#21231)
* fix(antigravity): recognize non-Gemini tui-idle prompts

* fix(antigravity): reject stale composer caret in model picker

* fix(antigravity): do not treat a wrap continuation caret as ready

An unsent composer can show `> draft` then an indented `>`. That continuation is not an empty input box, so tui-idle must stay false.

* test(antigravity): align later bare-caret status expectation

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-20 18:01:45 -07:00
Seongho BaeandCursor ea5152f1c2 fix(orchestration): line-settle delay for antigravity multiline paste (#21665)
* fix(orchestration): retry Enter after cursor-agent worker-start paste

Worker-start dispatches through bracketed paste in the main process; cursor-agent
can leave long prompts as "Pasted text +N lines" and swallow the first Enter.
Apply the same submitRetryDelayMs path Codex uses in the renderer, but only for
agents without the Claude/Codex render gate so hook turn-start reservation stays intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(orchestration): line-settle delay for antigravity multiline paste

Antigravity 1.2.x expands long bracketed paste slowly ("↑ N more lines") while
Orca only waited for byte ingest (~500 ms on macOS). Add submitLineSettleMsPerLine
and retry Enter for antigravity; wire agent-aware submit scheduling through the
main-process prompt writer and plain terminal.send suffix path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(orchestration): antigravity line-settle only; drop unverified retry

Address PR review: revert accidental pnpm-lock.yaml churn, remove cursor and
antigravity submitRetryDelayMs until live-verified, keep submitLineSettleMsPerLine
for agy multiline paste, and move the regression test out of the 900+ line runtime
submission suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 18:01:41 -07:00