Commit Graph
10162 Commits
Author SHA1 Message Date
Jinjing 42a42281bd Remove agent map view from dashboard (#15853)
* Remove agent map view from dashboard

Removes the view toggle and simplifies the dashboard to show only the kanban board layout.

* Assert boardProps is initialized on drawer open
2026-08-21 21:43:54 -07:00
Neil a157f4cfec fix(worktree): drop a just-added fork remote when its head fetch fails (#15850)
Fork-PR setup adds the contributor's remote, then fetches the head. If that
fetch fails the create aborts, but the remote stayed behind with no owner:
cleanup only runs on worktree removal, and no worktree was ever created. Each
retry then left another orphaned pr-* remote.

Roll the remote back on fetch failure, on both the local and SSH paths, and
only when this call is what added it -- a reused remote (Orca-created or not)
is left alone.
2026-08-21 21:24:19 -07:00
Denis Darii d5667376b0 feat(dashboard): add a keyboard shortcut to toggle the Agent Dashboard (#15353)
Adds a configurable, unbound-by-default `dashboard.toggle` action that toggles the Agent Dashboard (in-window drawer or pop-out, per the existing mode setting).

- Wired through window-shortcut-policy, main-window dispatch, browser-guest dispatch, preload, and the renderer IPC handler.
- Opening the in-window drawer reveals the sidebar first; closing leaves it alone.
- Gated on the `experimentalAgentDashboardPopout` experiment, and the Settings shortcut row is hidden while that experiment is off.
2026-08-21 21:12:12 -07:00
Neil b7e79b7ca6 fix(windows): one chokepoint for every child process (#15746)
* feat(process): add the Windows-correct child-process chokepoint

Six decisions have to be made every time Orca starts a child process --
console visibility, argument quoting, .cmd interpretation, binary
resolution, timeout policy, and how the tree is later terminated. POSIX
forgives all six. Windows punishes each differently, and made per-call
site across 172 files they were right in some and wrong in others.

runProcess/spawnProcess make them once:
- windowsHide unconditionally, shell:false unconditionally (shell:true
  concatenates argv unescaped and silently disables windowsHide)
- .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line,
  because Node refuses to spawn them otherwise (EINVAL)

The encoding was derived by measurement on Windows 11, not from the
docs. An embedded quote is written "" rather than \" so cmd's naive
quote count stays even -- with \" the parity flips and every later &
| < > on the line stops being data. Measured before the fix, argv
["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as
["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and
ran its remainder as a command. Each % is broken out of the quoted run
as "^%" because %VAR% expands even inside quotes.

The import-boundary test is a ratchet seeded at today's 172 files; it
only shrinks.

* fix(process): route the console-flashing spawn sites through the chokepoint

The ssh -G config probe fires on every connect and reconnect, and ssh.exe
is console-subsystem, so a GUI-subsystem parent gets a fresh visible
conhost that takes foreground -- keystrokes typed into an Orca terminal
at that moment go into the black box (#10488, #14543). Same for the
ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration
and the DPAPI cookie decrypt.

Also stops spawning powershell by bare name: PATH under Electron is not
the user's, so where policy has pruned the System32 entry the spawn fails
and the font picker silently reports five hardcoded families rather than
an error (#11771).

Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap
and kill are the chokepoint's job now. Adds runProcessSync so the sync
callers have a compliant path; without one the ratchet could never
reach zero.

The three suites that mocked child_process directly now mock runProcess,
which is the point: how a process gets started is no longer each
module's business. Ratchet 173 -> 170.

* fix(process): do not report a deliberately killed child as timed out

runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured:
a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so
does anything else that terminates the child -- and those cases set no
error at all. Reading the signal alone reports a process someone stopped
on purpose as having timed out, which callers retry.

* refactor(process): hold the ratchet as data and migrate the pwsh probes

The allowlist and the adversarial argument corpus are read only by tests,
so they were production modules in name only; they move to __fixtures__.

pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the
same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile
callback as a SIGTERM kill with no code. runProcess reports one timedOut
flag, so the helper and the reasoning behind it both go.

Its sync probe also spawned without windowsHide, which flashes a console
and steals foreground on every cold cache read.

* refactor(process): migrate five more spawn sites onto the chokepoint

Each one deletes a hand-rolled promise/timeout/kill wrapper and stops
re-deciding console visibility for itself. Ratchet 170 -> 164.

Two things this surfaced, both kept:

runProcess now accepts string chunks as well as buffers. A stream someone
called setEncoding on emits strings, and concatenating those as buffers
throws inside a data handler -- where the rejection has nowhere to go and
the caller simply hangs rather than failing.

ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS
PAM preflight passes one through from its own caller.

ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage
 pipeline detached so a timeout can reap the group with one
negative-pid SIGKILL; runProcess kills only the root, which would orphan
the plutil stages. Migrating it needs the chokepoint to own POSIX
process-group termination first -- the same guarantee job objects give on
Windows. Reverted and left on the ratchet.

* test(process): do not assert a POSIX signal on Windows

Windows has no signals, so the same deliberate kill reports an exit code
there and a signal on POSIX. What has to hold on both is that neither
shape reads as a timeout. Caught by running the suite on Windows.

(cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f)

* fix(process): settle a timed-out run even when the child ignores the kill

close only fires once the child is actually gone, so a child that traps
SIGTERM never emits it and the promise outlives its own deadline
forever. That is the same wedge shape just fixed for the process table,
and it is worse here: pwsh.ts and the snapshot reader both cache an
in-flight probe, so one unkillable child hands every later caller the
same dead promise.

After the deadline it now escalates to SIGKILL and settles regardless,
reporting timedOut with whatever output arrived.

(cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc)

* fix(process): escalate an aborted child too, not just a timed-out one

The grace escalation I added covered the timeout path and left abort on
the old one, so an aborted caller with an unkillable child still waited
forever -- the same defect, one path over. The macOS PAM preflight is a
real caller that passes an AbortSignal.

Both paths now share one stop-and-settle, and the result reports
timedOut honestly: false when the caller aborted.

(cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d)

* fix(windows): stop percent escaping from forging an escaped quote

escapePercentForCmd ran as a post-pass over the quoted string, so it
inserted a quote wherever a percent was -- including straight after a
backslash. CommandLineToArgvW reads backslash-quote as an escaped quote,
so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as
Windows paths get, and my 20-case corpus had no backslash-before-percent
entry to catch it.

Percent handling is now part of the quoting loop, where the backslash
run is known and can be doubled before the inserted quote. Two corpus
cases cover the shape.

The program path gets the same treatment. It was quoted but not
percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own
path expanded on the cmd hop.

quoteWindowsArgument no longer takes a boolean. Passing it to
values.map() handed map's index in as the flag -- which is how the first
version of this fix was written, and the corpus test caught it.

Separately: an AbortSignal that was already aborted never fires the
event, so runProcess ran the child to its full timeout for a caller who
had already given up.

(cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
2026-08-21 21:05:24 -07:00
Jinwoo Hong e7b047f53a fix(codex): suppress false restart notices after reauth (#15835) 2026-08-21 18:09:41 -07:00
Yeray 013eb539d5 Change 'SEÑOR' to 'MR' in Spanish locale (#15594) 2026-08-21 17:27:11 -07:00
Jinwoo Hong da6b9d8065 fix(terminal): stop orphaning live agent terminals across host restarts and graph syncs (#15644) 2026-08-21 17:11:17 -07:00
Neil 080c95940f fix(ssh): let fork-PR worktrees add their contributor remote via the relay (#15827)
* fix(ssh): let fork-PR worktrees add their contributor remote via the relay

Creating a workspace from a fork PR on an SSH host failed with "Destructive
git remote operations are not allowed via exec". The relay's git.exec
allowlist blocked every `remote` write subcommand, but SSH fork-PR creation
has to run `git remote add <fork> <url>` on the host before it can fetch and
track the contributor's branch, so the whole create aborted.

Allow exactly the two shapes that flow needs -- `remote add <name> <url>` and
`remote remove <name>` -- validated with the same remote-name and URL rules
the relay already applies to every pushTarget-carrying RPC. Everything else
(set-url, rename, prune, extra operands, flags before the action) stays
blocked, and the URL must be a github.com clone/ssh URL, so no new reach is
granted beyond what push/fetch already accept.

`remote remove` was blocked too, which silently leaked fork remotes on SSH
hosts: worktree removal swallows the cleanup error. It works again now.

A host still running an older relay gets an actionable "reconnect to deploy
the latest relay" message instead of the raw policy error.

* test(git-exec): pin remote read/write mutation classification

Misclassifying `git remote` / `remote get-url` as mutating would flush the
relay and SSH provider git read caches on every remote probe, so pin both
directions.
2026-08-21 14:44:08 -07:00
Brennan Benson 3fca1d1648 fix(linear): unbound list-issues by default, surface truncation, bind cursor workspace (#15824)
Fixes STA-5076.

list-issues capped at 50 by default and hard-clamped at 250, with hasMore buried
under result.meta and no stderr warning for --json, so a page that stopped early
read as a complete answer. Omitting --limit now walks Linear's pages until they
run out (meta.limit is null), and --limit <n> is the only cap, paging past
Linear's 250-per-request maximum to reach it. result.truncated sits next to
result.issues and is set only when a cap actually held results back; human output
prints "truncated: showing N".

The read still has to fit the CLI's 60s RPC budget, so a 20s wall-clock deadline
and a 200-page ceiling stop the walk early and report truncated with a
continuation cursor rather than failing the command.

Also:
- issued --cursor values bind the resolved workspace, so call -> nextCursor ->
  call works without --workspace; raw Linear cursors still need one and now carry
  nextSteps
- issued cursors whose payload smuggles back `all` or an empty workspace are
  rejected at decode, since either would widen the read past the bound workspace
- JSON issue rows carry priorityLabel (none/urgent/high/medium/low), matching
  orca linear priority set
- truncated and priorityLabel are optional on the wire, so a host that predates
  either is not read as "complete"; readers fall back to meta.hasMore
- the truncation line prints the rows actually rendered, so a remote result with
  no meta.returned cannot print "showing undefined"
2026-08-21 14:28:55 -07:00
Jinwoo Hong 8462fa72da fix(rate-limits): support Codex 0.149 approval policy (#15823) 2026-08-21 14:21:53 -07:00
Jinwoo Hong 1ce2e562b3 fix(skills): isolate concurrent upload staging (#15693) 2026-08-21 13:15:46 -07:00
OrcaWinandBrennan Benson 2a68b78bb3 fix(worktree): let a configured worktree base outrank a built-in visibility source (#15232) (#15430)
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-21 12:20:04 -07:00
github-actions[bot] 4fd93ead19 Update README downloads badge 2026-08-21 12:27:48 +00:00
VincentandOrcaWin 59892a2f13 fix(terminal): preserve Shift+Enter during routing confirmation (#13598)
* fix(terminal): preserve Shift+Enter during routing confirmation

Keep previously trusted CSI-u routing active only while local foreground revalidation is pending. Clear pending authority on inconclusive reads and never promote display-only identity.\n\nRefs #12541\nRefs #13597

* fix(terminal): respect global WSL routing gate

* fix(terminal): bound the retained routing capability to a live read

Two holes in the previous commit let one provider read authorize CSI-u
indefinitely.

First, routingConfirmationPending satisfied its own precondition, so a
pending entry re-published itself on every reconfirmation request. Only
one of the five callers is the Shift+Enter burst timer; the others fire
on accepted submit/interrupt bytes, focus and visibility changes, and
onAgentExited -- the last of which runs precisely when a shell title
proves the agent is gone. On cmd.exe and Git Bash there is no OSC
boundary to publish over the entry, so nothing decayed it.

Second, the flag was published even when no confirmation read was
actually scheduled -- a hidden pane, a command read already in flight, or
a null pty id -- and only the read's inconclusive settle clears it.

Require routingTrusted to grant the capability, and publish the flag only
after sampling reports a read in flight, so it cannot outlive the read
that justifies it. This also makes the canConfirmRouting gate redundant:
the tracker already refuses WSL, SSH and remote pty ids.

* review round 2: bound the retained capability to any in-flight read

onVisiblePtyBound refuses to schedule while a higher-authority command
read owns the pane, so gating the pending publish on it mistook 'a
command-finished read already owns this' for 'no read at all' and
dropped CSI-u for >=350ms — the window #13598 exists to close.

Ask the tracker whether any read is in flight instead, and settle the
visible confirmation from the command-finished branch that publishes
nothing, so the flag cannot outlive the read that justifies it.

* review round 2: tighten the reconfirmation gate and its comments

hasReadInFlight already covers the visible-pty read that
visibleForegroundSamplePending tracked, so the disjunction was
redundant. Collapse the stacked Why blocks into one.

* review round 3: release the retained capability when a read is abandoned

Every outcome path now clears routingConfirmationPending, but the two
abort guards in readForeground return without publishing or settling. A
pty rebind during the multi-second inspection RPC — a detach/remount
emits no onExit, so the store entry survives — therefore stranded the
flag permanently, leaving Shift+Enter on CSI-u with nothing left to
revalidate it. Worse, once sampling is suppressed by a live hook row the
pane routes bytes on hook evidence alone, which the resolver excludes
precisely because PTY output can forge it.

Settle an abandoned read unless a newer generation will settle for it.

* review round 4: release the retained capability at every exit without a successor

Rounds 2 and 3 closed the outcome paths and the aborted-read paths, but
two review lanes independently proved a third: cancelPendingRead bumps
the generation, so a cancel never settles, and dispose plus the three
untrackable early returns schedule no successor to settle for them. The
store entry outlives the tracker — detach/remount emits no PTY exit — so
the flag latched there with no read left alive.

A visible remount self-heals, but a hidden pane does not: the dashboard
card resolves the encoding for any pane key regardless of visibility.

Release the capability from whichever exit ends the read, and say in the
entry's own doc comment that the flag is Shift+Enter-scoped.

* test(terminal): pin the remaining capability-release exits

dispose and the command-finished exit were covered; the visible-bind and
command-start variants are the same three-line pattern and were not.
Neutering the release now kills all four.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-20 21:08:41 -07:00
VincentandOrcaWin a0cc9f35ba fix(terminal): mark captured shortcut input interactive (#13800)
* fix(terminal): mark captured shortcut input interactive

Refresh the interactive-redraw timestamp after a captured shortcut send
succeeds, so the composer redraw that follows takes the low-latency
foreground path instead of waiting out the 1s coalesce fallback.

Orca's captured shortcut path sends directly through the captured pane
transport to preserve pane, PTY and transport identity, and so bypasses
xterm's onData -- the only place that previously stamped the timestamp.
An idle pane therefore scheduled the post-Shift+Enter redraw as ordinary
throughput. Measured in a real Pi pane on Windows: ~1029ms before,
~16-20ms after, at an identical ~2.3KB redraw.

Stale pane bindings and rejected transport sends cannot refresh it.

Fixes #10203
Refs #13598

* fix(terminal): keep captured shortcuts out of the pane-teardown signal

lastTerminalInputAt has two readers, and the previous commit only meant
to change one of them. onExit reads it as "the user never typed into
this pane" to keep a newborn pane mounted when its shell dies on startup
(the failing-.envrc direnv case, pty-connection.ts onExit) so the error
stays visible and the worktree stays active.

Stamping it from a captured shortcut therefore made a single Shift+Enter
before that exit close the tab and bounce the user to Landing -- on every
platform, since captured shortcuts are not Windows-only.

Split the redraw window onto its own timestamp so captured shortcuts open
the fast path without arming the teardown, and leave onExit's behaviour
byte-identical to main.

* test(terminal): pin the captured-shortcut wiring and its staleness guard

Deleting the onAccepted block, or replacing its binding-identity check
with true, both left the whole suite green — so nothing pinned the part
of this PR that actually ships. Cover both against the existing IME
keyboard harness.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-20 21:08:20 -07:00
ppw-stack df7460af41 fix(ui): keep dialog title and footer inside the panel (#15707)
DialogContent used an implicit grid column, which sizes to min-content: an
unbreakable token such as a long filename in the title widened the column past
the panel, so the title overflowed and the justify-end footer buttons rendered
outside the visible surface. Pin the column to minmax(0,1fr) and let long
titles wrap.
2026-08-20 19:34:31 -07:00
Jinwoo Hong a0578641e9 fix(ci): restrict release test token permissions (STA-4970) (#15675) 2026-08-20 19:14:13 -07:00
OrcaWin 8b60a1e458 fix(grok): stop the Windows status hook from spawning two interpreters per event (#15595) 2026-08-20 19:11:51 -07:00
OrcaWin 2c24025e1d fix(worktrees): honor absolute Linux worktree base paths for WSL repos (STA-4772) (#15384) 2026-08-20 19:10:28 -07:00
Jinwoo Hong 012e9f410c fix(runtime): recover adopted tui-idle and pin worker fixtures (#15569) 2026-08-20 15:51:48 -07:00
Jinjing 4e058d4a52 Fix flaky CI tests by adding retry logic and increasing timeouts (#15635)
* Fix flaky CI tests by adding retry logic and increasing timeouts

Add Electron launch retry for CI runners where startup wedges before
reaching 'ready', with fresh profile per attempt to avoid mid-init state.
Increase skill install lock timeout from 100ms to 5s to account for
fsync cost plus retry duration on loaded CI runners.

* shorten comments
2026-08-20 12:53:44 -07:00
JinjingandJinwoo-H 6e25a90085 fix(terminal): keep a quick command queued until its own spawn takes it (#15630)
* Increase shell readiness timeout to match daemon barrier

Slow interactive rc files can take longer than 1.5s to initialize. Raise
the startup command readiness timeout from 1.5s to 15s to match the daemon
barrier and prevent queued commands from executing mid-startup.

* fix(terminal): keep a quick command queued until its own spawn takes it (STA-4876)

Triggering a quick command opened a terminal tab titled with the command's
label, the shell started and drew its prompt, and the command never ran.

TerminalPane snapshots `pendingStartupByTabId[tabId]` in a useState lazy
initializer, and a mount effect deleted the entry immediately. The pane's key
is `${tab.id}-${tab.generation ?? 0}`, so anything that bumps generation before
the command reaches a shell — the stall-recovery remount fired from
`requestTerminalPaneRecovery`, or the allDead activation regeneration — mounted
a second pane that re-read an emptied slot and spawned with no command at all.
The loss was permanent, which is why every scope failed alike: repo, global and
agent-prompt all funnel through the same queue-then-snapshot sequence.

Spend the entry at `onPtySpawn` instead, which is the one point that proves this
pane's own fresh spawn exists. A pane retired mid-connect never reaches it, so
the command stays queued for the next mount; reattach skips `onPtySpawn`, so it
cannot spend a command it never delivers.

Three details are load-bearing:

- Ownership is reference identity (`paneOwnsQueuedStartup`). Setup and issue
  splits borrow the same `deps.startup` field for their own one-shot payload, and
  that payload can be structurally identical to the queued command, so a
  truthiness test would let a split pane spend a command it never runs.
- The consume runs after `bindActivePanePty`. While the tab still has no ptyId,
  the queued entry is the only thing holding its worktree out of the
  retention-budget force-park, so dropping it first unmounts the pane mid-spawn.
- The callback is one-shot. `onPtySpawn` fires on every fresh spawn a pane makes,
  including hibernation wake and the respawn ladder, and a command queued after
  the first launch belongs to that later launch.

Known residual, documented at the call site: the consume tracks "a pty exists",
not "the command ran". Windows embeds short commands in the shell argv, so they
execute before the spawn resolves and a pane retired in that window re-delivers
on remount; on POSIX the write waits for shell-ready, so a pty that dies in that
window loses a command already spent. Closing either needs a delivery signal
from main rather than this callback. Both windows are narrow, and both are
strictly better than losing the command unconditionally.

* fix(terminal): guard the queued-startup wiring the review found untested

Follow-ups from the final review pass on this branch.

- Collapse the ownership + one-shot decision into `createQueuedStartupConsumer`
  so the call site is a single call rather than inline logic no test could
  reach. Two mutants survived the whole suite before this: relaxing ownership to
  a truthiness check, and dropping the one-shot guard. Both now fail.
- Rewrite the throwing-consume test. It asserted `updateTabPtyId` had been
  called, which runs *before* the callback, so it passed with the try/catch
  deleted. It now asserts the throw does not escape into the connect promise,
  which is the invariant the try/catch actually provides.
- Correct the `onQueuedStartupSpawned` docblock. It claimed the callback is "the
  first moment the command is guaranteed to reach a shell"; the diff's own caveat
  says otherwise, since Windows runs an argv-embedded command before this fires
  and a POSIX shell can die before the shell-ready write. It marks a live shell,
  not delivery.

No behavior change: the consumer is the same predicate and the same one-shot,
moved behind one exported seam.

* fix(terminal): roll shell wrapper isolation into a fresh daemon

* Revert "Increase shell readiness timeout to match daemon barrier"

This reverts commit 6ab273ef36.

* Condense queued startup spawn comment

Simplify the multi-paragraph explanation into a concise summary that captures the key points: spawn must wait until after the pane is bound to preserve the worktree from force-parking, and the behavior differs between POSIX and Windows for delivery timing.

* fix(terminal): prevent consuming replaced queued startup commands

When a queued startup is replaced before the pane's first spawn,
the one-shot guard alone still allows consuming the replacement.
Add isStillQueued callback to verify the slot still holds the
originally captured command (STA-4876).

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-08-20 12:15:08 -07:00
e41074cb1f docs(readme): add Android install guide link to README.ko.md (STA-4817) (#15566)
Taken from #15396. Matches the English README's install-guide link added in #14978.

Fixes #15395

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: erishforG <eric.signal@kakaocorp.com>
2026-08-20 10:45:08 -07:00
Jinjing d8e9fa1bb9 Revert "fix(terminal): apply pane padding on all four edges (#15544)" (#15623)
This reverts commit 4b2ed5ddd4.
2026-08-20 09:57:55 -07:00
github-actions[bot] 0f26ff4ad8 Update README downloads badge 2026-08-20 12:28:13 +00:00
Neil feaebabf2c test(terminal): pin the two conditions that keep macOS period substitution out (#15393)
macOS rewrites a double space into ". " and hands the period to the pty.
Orca is not immune to that: a plain Chromium textarea in the Electron
version pinned here does substitute, measured on hardware with real key
events, and spellcheck="false" does not prevent it. What prevents it is
that the forwarder claims a plain space keydown and then empties the
helper textarea, so the text system never sees the word preceding the
space.

Neither half was a decision. Before 01bcc8dca2 the claim predicate
excluded space, letters and digits, the field accumulated, and the
substitution fired on real hardware. That commit widened the predicate
for unrelated reasons - IME commit survival and kitty encoding - and
suppressed this as a side effect it never mentions. The predicate
already declines on modifiers and during composition, so a later
narrowing would return the bug with nothing to catch it.

This does not test the substitution, which a unit environment cannot
produce. It tests the two conditions measured to suppress it. Verified
non-vacuous: narrowing the predicate back toward punctuation fails three
of the four, and removing the blanking fails the fourth.

Refs #11504
2026-08-20 03:08:29 -07:00
NeilandBrennan c92f394cde fix(pty): delete the reply-withholding scheduler (#15578)
* fix(pty): answer a terminal colour query in its own turn

Root-cause follow-up to #15559, which stopped a CPR overtaking a deferred
colour reply but left the deferral itself in place.

Orca answers terminal queries by writing to the PTY master, which a line
discipline in ECHO copies straight back out as junk on a cooked prompt
(#12112). The guard was to withhold the write until an `stty` subprocess
proved ECHO clear — and forking is what forced the decision to be async.
Any deferral, however short, lets a reply written later in the same turn
overtake this one, so the async probe was the bug's root cause.

Read the bit synchronously instead. Linux and the BSDs redirect a
master's mode ioctls to the slave, so a `tcgetattr` on the master fd
node-pty already owns answers for the slave with no fork: measured 0.26us
against 2403us for the subprocess. With a verdict available inline, a
querying program that already cleared ECHO — every raw-mode prober,
including the colour probe behind the `gh auth login` report — is
answered in its own turn and can never be reordered.

The deferral stays for the genuinely cooked case, and the ordering
guarantee stays underneath it: hosts whose node-pty predates this patch
get no sync probe and fall back to the deferred path, which mixed
client/host versions make a live production path.

Reply routing is all-or-nothing: a payload needing neither containment
nor ordering stays on the host's own path, so a CPR answered during shell
startup cannot pass the daemon's post-ready flush gate and splice into
the buffered startup command.

Native side is fail-safe: a kernel that did not redirect would answer
from the master's own termios, whose ECHO defaults set, so the degraded
verdict is "echoing" — never a false "quiet". The JS half ships in the
pnpm patch while the binding needs a source build, so
ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 makes CI fail rather than silently
skip when it is handed an upstream prebuild.

Co-authored-by: Brennan <brennanb2025@users.noreply.github.com>

* fix(pty): keep the flush ordered under synchronous re-entry

Three defects found in external review of the reply-ordering work.

node-pty delivers onData inside the master write, so a query can be
answered while the queue is mid-flush. `flushPendingWrites` spliced the
array off before writing, so that reply saw an empty queue, took the
same-turn path, and landed ahead of entries the loop had not written yet
— reproduced as 01, 99, 02, 03. It now shifts one entry at a time so a
re-entrant reply queues behind the rest, bounded by the length at entry
so a re-entrant push cannot spin the loop.

An overflow flush can re-enter as far as teardown. `answer` did not
re-check `closed` afterwards, so it queued behind a closed delivery,
returned true, and the reply was never written and never reported.

The payload router's ownership comment overstated its guarantee. The
`any` semantics are deliberate — returning false after a constituent was
already written would have the caller re-write the whole payload and
duplicate it into the child's stdin — so the residual mixed-failure drop
is now documented rather than implied away.

* fix(pty): delete the reply-withholding scheduler

Orca answered a terminal query by withholding the write until a probe
proved the slave's ECHO bit was clear. That was the wrong mechanism, and
it is now gone: replies are written in the caller's turn and their echo
is contained on the output side, where it always was.

Withholding never removed an echo. The wait was bounded and always ended
in a write, so the output-side projections were doing the work the whole
time — including the readline rewrite, which happens with the tty already
raw and which therefore no reading of the ECHO bit can predict. What
withholding did add was an asynchronous write path, and that is what let
one reply overtake another and land in the next program's stdin (#15559),
what produced a re-entrancy inversion inside its own flush, and what four
rounds of regressions have lived in.

The last thing it covered was the verbatim echo of a `stty -echoctl` tty.
That shape is now projected directly. It starts with ESC, so it is
matched only when complete and never held as a partial: holding it would
take a bare trailing ESC from the query parser and an expired hold would
release it raw, so a query torn at its own ESC would never be answered.
Complete-match-only is what makes the shape safe to project at all.

Measured on a real pty: a cooked-mode master write is both echoed AND
delivered — ECHO copies the bytes without consuming them from the slave's
input queue, so a program arming raw mode with TCSANOW/TCSADRAIN (libuv's
setRawMode, hence every Node agent) still reads them. Only a TCSAFLUSH
switcher discards it, which it does on every terminal, none of which
gates a reply on termios state.

Deletes the pending-write queue, the async stty probe, the poll budget
and probe rate limit, the deadline-driven flush, and the answer/
answerInOrder split. Replies now leave in call order by construction.
No packaging, native or CI surface is touched.

* test(pty): restore stty-probe coverage and pin the duplicate-query retry

Archaeology on how withholding got here, and what its tests were really
protecting.

Deleting the ECHO probe took four tests with it that were not about the
probe at all: they cover createSttyProbe, which the shell-readiness
line-editor probe still uses — in-flight sharing, the per-platform stty
flag, and transient-versus-permanent failure latching. Restored against
the line-editor probe, which is now their only caller.

Also pins the property that answers the one case an immediate write
cannot serve. A program that queries while cooked and then arms raw mode
with TCSAFLUSH discards the reply with the rest of its input queue.
Nothing can prevent that from the terminal side, and no terminal tries.
What matters is that such a program re-queries after its own timeout: the
ingress declines to answer an already-answered slot but forwards the
duplicate downstream, so the renderer's emulator answers the retry, by
which point the program is raw. The retry path is the recovery, not
withholding.

* ci(pty): keep the fish real-PTY test in the shell-contracts lane only

Reverting pr.yml to main dropped the exclusion for the fish query-reply
test, which this branch keeps, so it would have run in the sharded lane
as well. Restores it to the shell-contracts include list and the shard
exclude list, and drops the parallelism expectations for the deleted
cooked-querier suite and the echo-state env guard.

---------

Co-authored-by: Brennan <brennanb2025@users.noreply.github.com>
2026-08-20 02:15:42 -07:00
Brennan Benson 21b66197be fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473) (#14924)
* fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473)

* fix(worktrees): bound outstanding retirement backfill listings, not just their rate

The scan deadline abandons a listing, it cannot cancel one: an unabortable readdir
keeps its libuv threadpool thread until the OS releases it. The failure backoff paced
retries but never counted the abandoned calls, so a mount that stayed wedged stacked a
new stuck thread on every lapse until the four-thread pool starved every other
filesystem user in the process. UNC reads were already bounded by the WSL gate's
permit accounting; plain SMB/NFS listings were not.

Cap the outstanding listings process-wide and serve the memoized failure once the cap
is reached. Recovery is preserved: a slot frees as soon as an abandoned listing settles.

* fix(worktrees): keep a late retirement listing, and stop deferrals penalising healthy repos

Three review findings on the scan bound:

- A listing that landed after the 15s deadline had its result discarded. Under the WSL gate
  that is the common case, not an edge one: the gate admits a single scan at a time and allows
  it 60s, four times this deadline. The namespace was then left unseeded on exactly the mounts
  this feature exists to cover. The answer is now kept when it arrives.
- A namespace deferred at the outstanding-listing cap never touched the wedged mount, so arming
  its backoff spread one bad mount's outage to repos on healthy disks. Deferrals no longer
  memoize; the next create retries as soon as a slot frees.
- The cap comment claimed it bounds threadpool starvation. It bounds this module's share only;
  the WSL gate and its close lane hold threads of their own. Comment corrected.

Also stub the WSL resolver in the UNC-root unit test, which otherwise shells out to wsl.exe and
boots the developer's distro on a Windows runner.

* fix(worktrees): only count stuck retirement listings, and fence the backoff on a monotonic clock

Two more review findings on the outstanding-listing cap:

- The cap counted every in-flight listing, not just the stuck ones, so it fired on healthy
  machines. Nested workspaces give each repo its own scan key, so a few first-time backfills are
  routinely in flight together; the third was rejected and its create then picked a name against
  an unseeded registry. Only listings that have outlived their deadline occupy a slot now, which
  is what the cap was always meant to bound — healthy scans finish in milliseconds.
- The backoff fence compared wall-clock times. The WSL gate this scan runs under deliberately
  avoids wall time for exactly this ('would misjudge stuckness across laptop sleep or NTP steps');
  a backward step pinned a namespace in its failure memo for the size of the step. Now monotonic.

A third finding — that a late listing writing into an entry a retry has replaced loses the answer —
was investigated and refuted. An entry is only ever read back through the map, and callers hold the
promise rather than the entry, so a write to a replaced entry cannot be observed. No guard added:
the test for it passed with and without one.

* fix(worktrees): gate retirement rescans per namespace, and keep a partial answer usable

Replaces the process-wide listing budget with a per-namespace rule, and stops a refused source
throwing away the sources that did read.

- A global budget was the wrong shape: one wedged mount spends it on its own retries (lapse,
  restack, lapse) and then every other namespace — including repos on healthy local disks — is
  refused for the process lifetime, which is a strictly larger blast radius than the wedge it
  replaced. A namespace now simply may not start a second listing while its own is still stuck,
  so a bad mount costs exactly one thread and nothing else is affected.
- Rethrowing a gate refusal abandoned the whole scan at the first source. For a WSL repo the UNC
  workspace root is listed first, so a stuck 9P route also discarded the plain, readable
  Windows-side bucket scan that needs no distro access — worse than the behaviour before the
  split. Discovery now returns what it read plus a "complete" flag; the names are used, and only
  the memoization is withheld so the hole is retried.
- An I/O failure was reported as a complete empty listing, so a transient EIO on a redirected or
  network home memoized "nothing is retired" for the process lifetime. Only ENOENT/ENOTDIR now
  count as a complete answer.

The recovery tests now release the stalled listing rather than leaving it pending forever: a
retry while the previous call is still stuck is precisely what stacks threads.

* fix(worktrees): keep the scan retryable when a WSL distro home will not resolve

Resolving a distro home shells out to wsl.exe, which returns nothing for a stopped or slow distro
(the call has a 5s timeout) or when wsl.exe is not resolvable from the Electron process. That case
dropped the distro bucket source silently and still reported the scan complete, so the empty answer
was memoized for the whole process lifetime.

That is the STA-4472 defect re-entering through the back door: the distro is exactly where a WSL
workspace's agent history lives, so a workspace whose directory is gone leaves its only surviving
evidence unread, and the next generated create reissues that cwd. It does not self-heal either —
the scan key is derived from the probe path, which is unchanged by a failed home resolution.

An unresolved distro now marks the scan incomplete, which serves the names that were found while
leaving the hole to be retried after the backoff.

* fix(worktrees): trace a WSL repo's Windows-side workspaces into the distro too

Distro discovery keyed only on the workspace root being a UNC path, but a WSL repo can legitimately
own workspaces under C:\. computeWorkspaceRootAsync mirrors the workspace dir into the distro only
when the distro home resolves at create time; when that wsl.exe call fails it falls back to the
drive path, and those workspaces stay on the Windows side.

The agent is still spawned through wsl.exe, so its cwd is the drvfs mirror (/mnt/c/...) and its
bucket lands in the distro's own ~/.claude/projects, where the host-home scan cannot see it. The
scan then reported complete and memoized the empty answer, so the name was reissued and the next
occupant inherited the previous conversation — the STA-4472 defect, in the one configuration the
UNC check does not cover.

Which distro to look in comes from the repo path rather than the workspace root, since that is what
still identifies the distro once the root is a drive path.

* revert drvfs-mirror discovery, and pin the "no agent state" classification

Reverts the previous commit. The drvfs branch traced a WSL repo's Windows-side workspaces into the
distro, but its production wiring cannot be pinned: the distro comes from parseWslPath(repo.path),
which short-circuits off win32, so no assertion on a Linux or macOS runner can reach it — deleting
the wiring line left every test green. Shipping an unpinnable branch is the exact unreached-module
shape this PR exists to close, and it is not worth it here: the branch only pays off in a narrow
race where getWslHomeAsync fails while the workspace root is computed and then succeeds seconds
later during discovery. Whenever the distro home resolves, the root is UNC and the existing path
already covers it; whenever it does not, the scan is already reported incomplete and retried.

Also adds the missing guard on the other side of the same classification: ENOENT and ENOTDIR mean
"no agent state on this machine", which is a complete answer. That is the common case for a fresh
or Codex-only install, and misclassifying it as incomplete would turn the one-time seed into a
60s-interval rescan for the life of the process. The expression had no test; it does now.

* test(worktrees): make the retirement backoff window a real assertion

The test that claimed to cover it settled the stalled listing and re-entered in the same tick, so
outstanding cleared only in a later microtask and the no-restack rule answered first. It was a duplicate
of the test above it, and the backoff clause it was meant to pin had no coverage at all: deleting
the clause left all twelve tests in both files green, so a regression that re-probes a wedged mount
on every generated create would have shipped.

Flush the microtask so the listing is genuinely settled, then assert both directions — the memo
still serves the failure inside the window, and the same call succeeds once the window lapses.

* fix(worktrees): stop trusting a UNC ENOENT, which is what a shut-down distro looks like

Windows reports an unreachable 9P route as ENOENT, so a distro that has merely been shut down is
indistinguishable from one that never held any buckets. wsl.ts already refuses to trust a UNC
ENOENT for the same reason, probing inside the distro instead.

The classification added earlier called ENOENT a complete answer, which is right for a local home
that simply has no agent state but wrong here. After a wsl --shutdown the cached distro home still
resolves, so nothing else marked the scan incomplete: the empty result was memoized for the whole
process lifetime and every later generated create in that namespace reissued names spent inside
the distro. That is STA-4472 again, reached by a different route.

ENOENT now only means "absent" off UNC.

* fix(worktrees): tell an absent distro directory apart from an unreachable 9P route

Distrusting every UNC ENOENT fixed the shut-down-distro hole but overshot: a distro where nobody
has run Claude genuinely has no ~/.claude/projects, which is the common case for Codex-only users
and for anyone running agents from the Windows side. Those namespaces could never report a
complete answer, so the one-time seed became a full rescan every 60s for the life of the process —
each one re-spawning wsl.exe and taking the single process-wide scan slot from transcript
discovery, on a path that runs on every composer open rather than only at create.

Probe the parent instead. If it lists, the child really is absent and the answer is complete; if it
does not, the route is down and the scan stays retryable. Both directions are pinned: reverting to
either of the previous behaviours turns a test red.

* fix(worktrees): walk up to a reachable ancestor, not just one level

The reachability probe checked a single parent, which only disambiguates when ~/.claude exists but
~/.claude/projects does not. The far more common shapes have the ancestors missing too: a distro
where Claude has never run has no ~/.claude at all, and a repo with no workspaces yet has neither
the workspace root nor its parent. In both, the one-level probe also got ENOENT and called the
route unreachable, which is exactly the 60s rescan loop it was added to prevent.

Walk up until a listing succeeds, bounded so a pathological path cannot hold the scan slot. One
reachable ancestor proves the route is up, so the ENOENT below it is real absence.

The test that was supposed to guard this had ~/.claude resolving, so the real shape was never
exercised — which is why the defect shipped green. Its fixture now leaves the whole chain absent
up to the distro home, and reverting to the one-level probe turns it red.

* docs(worktrees): record what gating retirement listings costs

The shared WSL filesystem gate admits one scan task process-wide, and its stuck-task check matches
scan against scan regardless of route. So retirement discovery now queues with — and on a wedged
distro can fast-fail — native-chat transcript discovery, which the ungated readdir it replaced
never could.

Gating is still right: the gate holds the only deadline and permit accounting these UNC reads get,
and without it a hung 9P route keeps a libuv thread outright. A dedicated lane would need a third
priority, which is a change to the gate rather than to this file. Writing the trade-off down so the
next reader does not have to rediscover it.

* perf(worktrees): probe reachability with stat, and record that the gate coupling runs both ways

The ancestor probe only asks whether a directory is there, but it listed it — enumerating a WSL
home over 9P, on the composer-open path, holding the single scan permit while it did. stat answers
the same question; the gate already supports the operation.

Also corrects the trade-off note added last commit, which recorded only the direction where
retirement discovery is the victim. Because the gate stuck-check matches scan against scan
regardless of route, the reverse is now true too and is the part this PR introduces: a retirement
listing wedged on one distro can fast-fail transcript discovery on a healthy one.

* fix(worktrees): drop imports the discovery extraction left unused

The rebase onto main kept main's import block, which still pulled readdir and
homedir for discovery code this branch moved into worktree-retirement-discovery.ts.

* fix(worktrees): drop the last import the discovery extraction left unused
2026-08-20 01:45:17 -07:00
Brennan Benson 3e079debec fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546)
* fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins

A project checked out on several hosts emits one discovery-notice row per
checkout, and those rows only named the project. A sidebar with paired remote
hosts therefore showed several identical "N hidden worktrees" buttons under one
project header, with no way to tell which machine each belonged to — or that
one of them was another machine's worktree inbox entirely.

Two causes, both fixed here:

- Notice rows carried no host context, unlike worktree rows, which have been
  host-labelled since STA-4343. Both notice rows now take a host label, applied
  per project (not per rendered section, since a card can land in the pinned
  fallback) and only when that project spans hosts. The label also lands in the
  review, expand, and dismiss accessible names, so the actions that write to a
  specific host's repo record say which host that is.

- One machine registered as a direct SSH target *and* paired as a runtime
  environment gives a single on-disk checkout two repo records with independent
  hidden-worktree state, so it emitted two rows for one directory. Repos now
  resolve to a (hostname, path) checkout key, and twins collapse to the record
  this client persists itself — its visibility state is the user's own and
  survives the paired runtime going away.

The key is deliberately conservative: an unresolved hostname, or a tunnelled
environment answering on loopback, yields no key and never collapses anything.
Renderer-only; no wire or persistence change.

* fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids

Replaces this branch's second change after a plan review found it has no
precedent and eight concrete failure modes.

Deleted: the (hostname, path) checkout key that collapsed two repo records
believed to be one machine. Orca models a direct SSH target and a paired
runtime environment as different execution hosts everywhere else; that change
asserted sameness by resolving strings a user typed in two places. It also
dropped rows (a differing count vanished with the shadowed record), flipped
with the sidebar host filter, ignored port and user so a host and a container
on it could merge, tie-broke on repo-store order, was disabled in the one case
Orca can prove (a tunnelled pairing answers on loopback) and fired only on
coincidence, and left the visibility dialog showing state the sidebar had
hidden. Its module also carried a literal NUL byte, so git classified the file
as binary and the diff was unreviewable.

Kept, with two corrections: notice rows still carry a host label, but the gate
now counts distinct host ids rather than distinct label strings — two hosts
sharing one user-facing label is exactly when the rows are hardest to tell
apart — and membership is read from the unfiltered repo universe rather than
the host-filtered notice candidates, so a label no longer appears and
disappears with the filter.

Two hosts that share a label still render the same label. Disambiguating that
is a shared concern across worktree badges, host headers, and host-filter
options, and needs its own design; three verification passes each found a
different hole in doing it here. Follow-ups: general host-label collision, and
the repo-record duplication that produces the twin rows in the first place.

* fix(i18n): catalog notice host scope copy

* feat(sidebar): show each notice row's host with the project-on-host glyph

Notice rows on a multi-host project already carried a host label, but two
hosts can share one user-facing name, and the label truncates first in a
narrow sidebar. Each row now also carries its host's glyph.

Deliberately the same indicator worktree cards use (worktree-card-header):
a Server glyph, ServerOff when a paired runtime has no live status, and a
"Project on ..." tooltip naming the host — SSH and runtime keep their
distinct tooltip wording. Local hosts draw nothing, as on the cards.

The glyph is shrink-0, so unlike the text label it survives the sidebar
narrowing, and the row keeps an identifying mark either way.

Rows now carry the host id alongside the label, since the label alone cannot
select a glyph or its tooltip. Catalog entries for the new copy ship with the
change rather than relying on inline fallbacks.

* refactor(sidebar): draw notice-row hosts with the shared host glyph

Follow-up to the notice-row host indicator: use the one glyph vocabulary the
app already has instead of a second copy of it.

HostRowIcon — a monitor for this computer, a server for anything remote — was
private to the composer's run-target rows. Moved to a shared home and reused,
so the sidebar and the composer cannot drift apart. The run-target module
re-exports it, leaving its own call sites untouched.

Every notice row now gets a glyph, local included, so no row is the odd one
out; the tooltip still names the host and says when a paired runtime has no
live status. Same size and tone tokens across kinds, so no row reads as
decorated relative to its neighbours.

* fix(sidebar): make notice host glyphs accessible
2026-08-20 01:38:28 -07:00
Brennan Benson 4b2ed5ddd4 fix(terminal): apply pane padding on all four edges (#15544)
* fix(terminal): apply pane padding on all four edges

Move the configured inset onto xterm so the terminal fills its pane while the fit calculation accounts for both sides of each axis. Add a geometry golden that forces cell remainders and verifies dynamic padding without relying on renderer pixels.

* fix(terminal): normalize imported padding for fitting

* fix(terminal): align stored and fitted padding
2026-08-20 01:07:56 -07:00
Brennan Benson 5ca747dad0 docs(ssh): state the SSH execution boundary and pin the liveness vocabulary (#14971)
* docs(ssh): state the SSH execution boundary and pin the liveness vocabulary

Nothing under docs/ described how work splits between the client and an SSH
host, so agents and humans inferred it from error strings and got it wrong:
loss of contact was repeatedly reported as process death, which orphaned live
remote agents and cold-started duplicates over the same worktree.

Pins the vocabulary to the incumbent live/unverifiable/exited verdict from
unstopped-pty-verification so no synonym is introduced, records the one real
discriminator (all of a host's terminals drop together on link loss; one alone
means process exit), and lists the outstanding gaps with citations.

Tracked via the docs allow-list and linked from AGENTS.md, per the convention
in .gitignore.

* docs(ssh): cite the live restoreRequired site after it moved

The throw now lives in reattachSshPtySessionForSpawn; ssh-pty-provider.ts no
longer contains it. Caught by the worker fixing it, against a newer main than
the audit ran on.

* docs(ssh): require host evidence for liveness verdicts

* docs(ssh): keep boundary references stable

* docs(ssh): fence liveness evidence to its host identity

* docs(ssh): state replay and environment boundaries precisely

* docs(ssh): correct replay and platform boundary claims

* docs(ssh): describe headless runtime continuity accurately

* docs(ssh): distinguish authority from client metadata

* docs(ssh): describe pending fixes accurately

* docs(ssh): date the gap list and name the PR that closes each entry

The Known gaps section was accurate when written and becomes actively
misleading as its fixes land: it told a reader to go fix restoreRequired,
the missing unverifiable verdict, and the absent terminal-list host field,
three things now addressed by #14974, #14977 and #14973.

Mark the section as dated, require verification against current code before
acting on any entry, name the PR per entry, and move landed items out. Also
correct the two body claims that the landed fixes invalidated. The rules
above are durable; only this section rots.

* docs(ssh): make the boundary doc a durable ruleset, not an incident record

The Known gaps section was 18 of 93 lines enumerating specific defects from
one investigation, several already fixed by sibling PRs in the same batch. A
reference doc that needs a 'this section rots' warning is telling you the
section belongs somewhere else; those entries belong in issues.

Replace the six-row table of currently-lying signals with the method that
outlands any particular bug: ask whether the owning host produced the signal,
whether every PTY on the target went quiet together, whether the termination
event matches the current incarnation and generation, and whether a returned
status is actually a claim. Same for artifacts - state what ls-remote and a PR
head each do and do not prove, rather than listing which command is currently
wrong.

Nothing here goes stale when the open fixes land.
2026-08-20 00:47:22 -07:00
Brennan Benson 2e895da937 fix(browser): name the requesting frame and the permission in denial notices (#15542)
* fix(browser): name the requesting frame and the permission in denial notices

Two defects in the same notice, both found by the review of #15481 and left out
of it deliberately.

The notice named the wrong site. setPermissionRequestHandler passed
webContents.getURL(), which is the top-level document, so a permission request
from a cross-origin sub-frame was attributed to the embedder. Every
PermissionRequest variant carries requestingUrl, so all three call sites now use
it and fall back to the top-level document only when it is absent.

The notice also showed raw Chromium permission names. humanizePermission mapped
two permissions and returned the raw token for the rest. That now matters more:
#15481 granted ordinary storage-access and left top-level-storage-access denied,
making it the storage denial a user can still hit - rendered as its raw token.

The default still returns the raw token. Inventing prose for a permission nobody
has seen is worse than showing its real name.

Does not change any permission verdict, and does not fix Google sign-in (#15221).

* fix(browser): keep permission denial attribution accurate

Capture fallback URLs before asynchronous media permission handling and treat opaque requesters as unknown rather than blaming the top-level page. Clarify permission descriptions and cover origin normalization, navigation races, and mapped copy.
2026-08-20 00:22:59 -07:00
OrcaWin 471bc9d8ce Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) 2026-08-20 00:16:04 -07:00
Neil 9d06b3ba93 ci: stop docs-only commits from starting the skill-roundtrip matrix (#15474)
A cancelled Skill update round trip on a README merge painted main red
because push to main had no path filter. Share the PR path list on
push, and skip expensive PR Checks when every changed file is docs.
2026-08-19 23:33:05 -07:00
Brennan Benson 9a898a84cc fix(terminal): keep xterm's render pause latched for a pane with no layout box (#15555)
* fix(terminal): keep xterm's render pause latched for a pane with no layout box

resetWebglTextureAtlas() released xterm's paused-render gate for every pane of
a visible manager, including panes that are display:none (a collapsed sibling
of an expanded pane, a restore that stays display:none for its whole reattach).

forceRepaintThroughRenderPause exists for a pane that is already DOM-visible
while xterm's IntersectionObserver lags a frame. On a pane with no box it
paints the freshly cleared render model into nothing, and because the observer
only fires on a state change it never re-pauses the service. It also clears
_needsFullRefresh, which is the only thing that makes _handleIntersectionChange
repaint on reveal and flush the deferred _pausedResizeTask.

Latch instead for those panes: terminal.refresh() re-arms _needsFullRefresh and
xterm repaints from it on reveal.

* test(terminal): type the render-service repaint mock
2026-08-19 23:25:47 -07:00
Neil 4daace6251 fix(tabs): stop split workspaces multiplying columns on return (#15482) 2026-08-19 22:54:03 -07:00
Neil ef096d539d fix(terminal): refuse a cursor on a screen read, and correct the source docs (#15563)
Review follow-up on #15380.

The RPC accepted `cursor` and `screen` together. The CLI refuses the pair, but
terminal.read is reachable without it, and honoring both answered with rendered
lines carrying the stream's pagination metadata — two frames of reference in one
payload, which is the confusion `source` exists to remove. The guard beside it,
withVisibleSnapshotFallback, already declines to substitute rendered lines when
a cursor is present; the screen path now agrees, at the RPC boundary where every
remote caller passes. Nothing could previously send both, since `screen` did not
exist, so rejecting breaks no existing caller.

The command notes and the runtime comment both still described the fallback as
`source: stream`, left over from renaming that value to `screen-unavailable`
during implementation. The spec text is surfaced through `orca help` and the
agent-context schema, so a caller following it would test for a value the code
never emits. Both now describe all four states, including that an absent source
means the host predates the field.
2026-08-19 22:42:39 -07:00
Jinjing acbcb477a1 Auto e2e tests autofix scheduled ci 1h run 1 20260818T2143 (#15379)
* fix: update E2E tests for API changes and selector robustness

- Improve source control file locator specificity to avoid flakiness
- Fix board test to use correct worktree ID attribute
- Update removeWorktree calls to pass host ID parameter
- Simplify git status polling with timeout expectation

* fix: increase packaged-watchdog launch timeout and await git-status rows

Extract hardcoded 15s launch timeout to a 30s constant for better reliability under load. E2E test now waits for all git-status rows to render before asserting absence of status messages, preventing flaky passes when the list is still loading.
2026-08-19 22:39:48 -07:00
Neil c72a4eecdd refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391)
* refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786)

Orca needs to run code after the user's own zsh startup files. It bought that by
keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and
sourcing each user file by hand -- four generated files per transport, with a
fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a
whole bug family:

- /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so
  history landed inside Orca's own dir (#11044), and an epilogue had to repair it.
- zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a
  user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file.
  The emulation degrade blocks and their forked $(emulate) probes exist for that.
- One wrapper dir shared by two installed builds could mix files from both, so
  every generated file had to redefine the helpers it called.
- The baked generation-time ZDOTDIR literal is unusable when a Windows-generated
  wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be
  re-derived from %x.

The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a
precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc
and .zlogin, every one of which zsh reads from the user's own directory exactly
as in an unwrapped shell. Each bug above stops being reachable rather than being
repaired, and their machinery goes with them: eight of thirteen exported blocks in
shell-templates.ts, both drifted discovery bodies (unifying them closes the
"reconciling the two is a follow-up" note the file carried), and the relay's
separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across
twelve files to 143 in three.

Two things the design has to get right, both found by running it rather than
reasoning about it:

- Every function is defined ABOVE the source of the user's .zshenv. A user file
  ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and
  the first prototype died there with `parse error near '\n'` -- silently, leaving
  the pane unwrapped. Function bodies are parsed at definition time.
- ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it
  resolved a usable dir, but a pane inherits its parent's environment too, so a
  stale value from an older build can arrive on its own and would point ZDOTDIR
  back at a wrapper dir. The ownership check Node applies now also runs in the
  shell, where that route is visible.

Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is
absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does.

Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs
in a shell started with -c, so the existing `zsh -i -c` probes could not have
exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the
shell to a prompt and reports through a file rather than stdout, which a PTY echoes.

* test(shell): cover the relay variant of the zsh hook in a real shell

The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the
agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR
shape as well, which is how it drifted from the desktop template in the first
place; now the spec flags are the only difference, and this pins that.

* fix(shell): rebase the single-file hook onto content-addressed wrapper trees

#15285 landed content-addressed wrapper roots and a per-transport fileset module
while this branch was in flight. The fileset modules are now the single place the
tree is described, so 'only .zshenv' is stated once per transport and the
required-paths check follows from it rather than repeating the list.

* test(shell): point the mixed-build proof at the relay, the one fixed wrapper path

#15285 content-addressed the desktop and daemon trees, so two builds can no
longer write the same directory there and the scenario this file covers became
unreachable on those paths. The relay still writes a fixed ~/.orca-relay/
shell-ready, so that is where the hazard survives and where the proof belongs.

* fix(test): make the zsh PTY harness survive a startup that stops to ask

Two CI-only failures, both from driving a real PTY where the old probes drove a
pipe:

- A host whose global zshrc runs `compinit` over directories it considers
  insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the
  question; a PTY sits at it until the timeout. The harness now answers it.
  ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain
  compinit ignores it, which I confirmed by reproducing the prompt locally.
- The PS1 line was typed at t=0, so on such a host the question consumed it as
  its answer. The harness now waits for the shell to fall quiet first, which
  also stops a slow prompt framework racing the same write.

Also merges a duplicate vitest import the native code-quality audit flagged.

* fix(test): stop the live-shell assertions assuming macOS host behaviour

Two of them hardcoded what my machine does rather than what Orca owes:

- LINEINIT was pinned to 'none'. A host whose global zsh config installs its own
  zle-line-init widget has one either way; the contract is that it looks the same
  wrapped as unwrapped, which the assertion beside it already states.
- The dropped-precmd_functions case asserted HISTFILE was no longer the scoped
  path. Whether the scoped value survives at all is the host's call: macOS
  /etc/zshrc overwrites HISTFILE so it does not, and a host with no such
  assignment keeps whatever the spawn env set. Now compared against an unwrapped
  pane given the same env, which is the real contract on both.

Also notes, where the emulation cases live, that they only discriminate on a host
whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion
is ORCA_HISTFILE having been consumed.

* test(shell): re-pin the fixes the four-file wrapper was built for

Archaeology over the removed blocks: each existed for a bug, so each needs the
bug shown to be unreachable rather than just the code gone. Six restored or added,
each naming the change that introduced the behaviour.

- #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII
  (token-range) one. The old file baked its generation-time path in and had to
  re-derive the runtime one from %x to avoid using it; this one bakes nothing.
  Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc
  loaded' cannot pass on a pane that never read the wrapper at all.
- #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin
  and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR
  around each source; this one never takes it away, and the values now have to
  match an unwrapped pane's.
- #1947: a user .zshenv that returns early.
- #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI
  proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when
  it resolved a usable dir, but a pane inherits its parent's environment too.
- #11044/#11146: a nested Orca inherits neither cross-process channel and no
  ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather
  than repaired. Verified the child-env probe detects a real leak before trusting
  it to report the absence of one.
2026-08-19 22:37:42 -07:00
Neil fdd4091ebd fix(hooks): isolate lint-staged backups per worktree (#15388) 2026-08-19 22:37:02 -07:00
Neil d7a23c84a9 fix(pty): keep a CPR reply from overtaking a deferred colour reply (#15559)
A background-colour probe writes `OSC 11 ;? ST` then `CSI 6n` and reads
exactly one response, using the CPR as its sentinel: a non-OSC first
response means "unsupported" and it stops draining. #13309 routed live
cooked-echo-risk replies through the ECHO-probe deferral while CPR kept
the immediate path, so the CPR overtook the colour reply, the prober gave
up, and the stray `ESC ]` was left in the tty for the next program —
`gh auth login` died on it with an escape-sequence error.

Queue a reply that needs no echo containment behind ones that do, FIFO,
and only while something is actually deferred, so latency-critical
replies stay immediate on every other path. Windows is unaffected: only
posix-pty defers, so the queue is always empty there.

Also: an in-flight echo probe is already the write continuation, so
re-arming the timer for a queued reply would fork a second stty and throw
away the first verdict; and teardown now hands queued uncontained writes
to the pty best-effort instead of dropping bytes the caller was told were
sent.

Known scope limit, pinned by tests and tracked for follow-up: the
guarantee is FIFO among recognised query replies, not over every byte —
a reply coalesced with a keystroke, and ordinary typed input, still
bypass the queue. Both were unordered before this change too.
2026-08-19 20:46:12 -07:00
Jinjing d541982b9c Improve cmd j search usefulness (#15551)
* Show last active time for workspace tabs in cmd+j palette

Adds session-age formatting and activity tracking to help users find
recently-used tabs. Replaces host badge display with last-active timestamps
that reflect either agent activity or worktree PTY activity, whichever is
more recent.

* Improve cmd+j search ranking with direct fields and recency

Prioritize results matching direct fields (titles, content) over
container fields (worktree, branch, repo). Use tab focus time to break
ranking ties. Makes search more useful for quick navigation.

* Extract path flavor logic to cross-platform-path utility

- Remove local pathFlavor function in favor of shared cross-platform utilities
- Simplify buildExcludePathPrefixes to use relativePathInsideRoot and resolveRuntimePath
- Ensures consistent path handling for both local and remote roots

* Improve cmd+j search ranking with recency-based tiebreaking

Track lastFocusedAt on tab creation/focus and use it to break ties between equally-ranked search results. This surfaces recently-used items first, improving search utility. Also fixes hasDirectHit to check field matches directly rather than evidence metadata.
2026-08-19 19:46:45 -07:00
Brennan Benson a76a95d111 fix(terminal): make remote-host take-back release the phone-fit lock (#15473)
* fix(terminal): make remote-host take-back release the phone-fit lock

The remote-desktop branch of reclaimTerminalForDesktop was the one path that
rolled the presence lock back when its reclaim resize did not converge. On a
remote/SSH host that left the phone-fit banner stranded and made every
subsequent "Take back all terminals" click a no-op. Its sibling (active mobile
subscriber) released the lock but still reported the layout's `ok`, so the
desktop renderer skipped its post-take-back refit and focus.

Both now follow the guarantee the method already documents: an explicit desktop
take-back always drops the lock, and the trailing remote layout is best-effort.

* review: pin the driver flip and correct the applyMobileDisplayMode contract

- Tighten the held-branch driver assertion to the exact post-release state so
  it pins releaseDesktopTakeBack's flip rather than merely "not mobile".
- applyMobileDisplayMode's doc claimed reclaimTerminalForDesktop gates its
  transitions on the returned convergence flag. No branch does after this
  change; say so, so the gate is not reinstated.

Hooks skipped (machine load); oxfmt --check and oxlint verified clean manually.

* test(terminal): pin the converging remote take-back resize

The two take-back tests both force the reclaim resize to fail, so nothing
covered a take-back that converges. Deleting the `idle` driver flip left
every suite green while applyRemoteDesktopLayout no-opped on a still-mobile
driver — lock dropped, `true` returned, PTY stranded at the phone grid.
2026-08-19 18:13:57 -07:00
Brennan Benson 59c5624e14 fix(terminal): require a proven exit before retiring a subscription's lease (#15470)
isPtyKnownExited read a PTY record as exited whenever `connected` was false:

  if (pty) { return !pty.connected }

Its own leaf fallback, one line below, demands getTerminalState(leaf) === 'exited',
which is only true once lastExitCode is set. So the same function proves absence on
one path and infers it on the other, and the inferring path is the one that runs
whenever a record exists.

onPtyExit is the only writer of lastExitCode. The liveness sweep clears `connected`
with no exit code for every PTY behind a dropped relay, so that state is a lost
connection to a process that may still be running on the host — not an exit. Reading
it as one makes subscribeToPtyExit fire its listener synchronously at subscribe time,
which retires the lease and emits `end` for a live terminal. Mobile reads `end` as
"PTY gone" and rearms; after three attempts it stops and leaves the composer on
"Waiting for terminal…", which is where the 0.0.44 permanent lock comes from.

Use the runtime's existing three-valued discriminator so both paths demand the same
proof. 'unknown' now keeps watching, and the later real exit still fires the listener;
a subscription that outlives its PTY is still bounded by the connection abort.

Both callers are in subscribeToPtyExit — the subscribe-time fast path and the
registration-race re-check — and both want proven-exited, so neither changes shape.
2026-08-19 18:13:26 -07:00
Brennan Benson 6ac79ef0aa fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken (#15540)
* fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken

The app has one modal slot, so any openModal/closeModal evicts whatever
held it. A pending orca.yaml trust prompt owns the promise that quick
create awaits, and eviction dropped its resolver: that submit never
settled, and because the trust prompts are serialized on a module-global
chain, every later create/remove in the session silently did nothing.

Modal data can now carry an onModalDismissed callback that the slot
invokes when it evicts an entry; the trust prompt uses it to resolve as
'skip', which is what dismissing the dialog already means.

* fix(workspaces): make trust prompt settlement one-shot
2026-08-19 18:12:29 -07:00
Brennan Benson 30bf2647fc fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it (#15472)
* fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it

A socket close or response timeout rejects an in-flight worktree.create as
delivery-unknown: the frame reached the wire, so the host may already have
built the worktree. The client only replayed connection-migration cutovers,
so every other ambiguity surfaced as a create failure for a create that may
well have succeeded. Replay on the same clientMutationId — which the host
already dedupes — after waiting for the transport to come back.

* fix(mobile): bound the ambiguous worktree.create replay by the host's dedupe window

The replay was bounded only by a retry count, but what makes a replay reconcile
instead of building a second worktree is wall clock: the host drops a settled
create's dedupe record 60s after it resolves, and past that the replay is just a
fresh create that the host's suffix loop happily duplicates — for a folder
workspace, into a second workspace with the very same name and no collision
check at all.

Two paths ran past that window:

- The request-timeout path. A silently dropped response frame leaves the socket
  alive, so nothing rejects until WORKTREE_CREATE_TIMEOUT_MS — ten minutes, with
  no bound at all on when the host actually resolved. This was previously the
  path that replayed *soonest*, short-circuiting the reconnect wait because the
  transport still looked healthy. Invert it: every path that reports a real drop
  has already left 'connected' by the time the rejection surfaces, so still being
  'connected' identifies the timeout and is now refused.
- The reported-drop path. Worst-case detection is a full liveness idle period
  plus the missed-probe budget before the client even learns the socket is dead,
  and the old 20s wait on top of that overran the record. Derive the wait from
  the watchdog constants and the TTL instead of hardcoding it, and anchor a
  single deadline at the first ambiguity so a second wait gets the remainder
  rather than restarting.

The TTL now has one definition shared by both processes, so the client asserts
its budget against the host's real window instead of a copied literal.

* fix(mobile): end the reconnect wait on a revoked pairing, and pin the wait's behavior

waitForRpcClientReconnected resolves only on 'connected' or the timeout, but an
'auth-failed' client never reaches 'connected' — so a create interrupted by a
revoked pairing sat out the full wait before surfacing the error it already had.
Treat auth-failed as a terminal answer on both the fast path and the listener.

The helper also shipped with no tests of its own: its already-connected fast path,
its timeout path, and the synchronous-notification-during-subscribe teardown were
only ever exercised indirectly through the retry suite, and neither RpcClient
implementation notifies synchronously, so that branch had no coverage at all. Add
a direct suite covering all of them, asserting listener and timer teardown rather
than just the resolved value.

Also give the fake-timer tests an explicit timeout. advanceTimersByTimeAsync
yields through real macrotasks between ticks while vitest's own budget runs on
real time, so on a loaded runner the default 5s is reachable — observed once as a
spurious timeout in this suite.

* fix(mobile): bound the ambiguous replay in wall clock, not timer time

The replay window was derived from the liveness watchdog's own budget
(idle + missed probes x probe timeout). That is a bound on how long the
watchdog takes to *fire*, not on how much wall clock passed. iOS and
Android suspend JS timers while the app is backgrounded, so across a
background cycle the socket dies silently and the pending create rejects
delivery-unknown minutes later with the timer-derived ceiling still
reading ~44s. The replay then lands well past the host's 60s dedupe
record and the suffix loop builds a SECOND worktree - for a folder
workspace, one with the very same name and no collision check at all.

Anchor the deadline on the watchdog's lastInboundAt instead: a wall-clock
stamp of a frame that really arrived, so it stays honest across a
suspension. Fall back to the send time when the transport can't vouch for
one (relay sessions run with idleProbeMs: null), which errs toward
refusing the replay.

Also restore the delivery-unknown discrimination test that the
still-connected guard had made vacuous, pin the still-connected guard
itself against a live inbound stamp, and pin the deadline against being
re-read from a fresher replacement session.
2026-08-19 18:10:39 -07:00
Neil 36d78e88af fix(agent-hooks): stop Antigravity's Windows hook from spawning PowerShell on every event (#15520)
Antigravity was the last agent posting hook status through Windows PowerShell 5.1. Every
hook event — roughly one every 2-6s during an active session — paid a ~300ms interpreter
cold start, which is what made the console the agent allocates for each hook last long
enough to be seen as continuous flashing.

Move the Windows POST to the shared curl.exe builder every other agent already uses, via
the `extraFormLines` escape hatch for the `hook_event_name` field Antigravity uniquely
needs. Measured on Windows 11: 326ms -> 134ms per event.

Because the curl line percent-expands its arguments, the script also needs
`setlocal DisableDelayedExpansion` (#9358/#9941) so a `!` in a pane key or worktree path
is not eaten as a delayed reference.

curl omits a `--data-urlencode name@-` field entirely when stdin is empty, so accept an
absent or blank Antigravity payload as `{}` at the ingest boundary — the POSIX script
substitutes `{}` before posting and PowerShell did the same, and without this a
payload-less event lost the status transition its `hook_event_name` still carried. Scoped
to that source; every other agent keeps rejecting a body it cannot parse.

Adds a cross-agent guard asserting the invariant the original drift violated: a managed
Windows .cmd hook posts through fully-qualified curl.exe and spawns no interpreter.
Generated under a mocked win32 platform so the POSIX CI legs guard it too.

Validated on a real Windows 11 host, not an emulated platform check.

Fixes #15117
2026-08-19 17:36:33 -07:00
Neil cb95582cea feat(release): build unsigned Windows artifacts for the dev channels (#15465) 2026-08-19 17:34:27 -07:00
Jinwoo Hong 0e5b348414 fix(repos): keep the desktop-owned manual project order authoritative across paired clients and SSH catalog publishes (STA-4850) (#15538) 2026-08-19 17:26:16 -07:00
Neil 9d1dfc314f fix(cli): resolve host names across both kinds, and stop ssh: answering empty (#15449)
* fix(cli): resolve host names across both kinds, and stop ssh: answering empty

`--host ssh:<id>` was never validated. An unknown target filtered to nothing and
returned ok:true with an empty list — the same silent wrong-machine answer that
unknown `runtime:` ids gave before they were rejected. And because SSH target
ids are machine-generated (`ssh-<timestamp>-<random>`) while the name anyone
actually knows is the label, this fired on the ordinary spelling rather than a
rare typo: every human-typed SSH name missed.

The two kinds of remote machine are also reached on different axes. A paired
Orca server is a connection (`--environment <name>`); an SSH target is a machine
the connected host reaches (`--host ssh:<id>`). A caller only knows "the machine
called X", so naming X on the wrong axis was the common failure and produced
either an empty answer or a dead-end "unknown environment".

Now: `ssh:` resolves labels as well as ids and rejects an unknown target with the
known ones listed; `runtime:` accepts the environment name as well as its id,
matching --environment, and canonicalizes to the id so stored host ids still
compare; and when a name misses on one axis but exists on the other, the error
says which and gives the exact flag. Candidates ride along in error.data so an
agent can recover without parsing prose.

`orca host list` is the discovery surface that was missing entirely — nothing in
the CLI listed SSH targets, so a caller told to use one had nowhere to look. It
prints this machine, the SSH targets registered on the connected host, and the
paired servers, each with the selector to use.

* fix(cli): give --environment the same cross-kind hint, and validate the ssh host on setup-create

Two gaps a follow-up survey found in the first pass.

`--environment openclaw` still dead-ended with a bare "Unknown environment"
while an SSH target by that name sat right there — the inverse of the case just
fixed, and the direction the report actually hit. The store's own error cannot
carry the hint: translateStoreError forwards code and message and drops data. So
the selector is resolved before the client is built, where the payload survives.
Only the explicit flag is asserted eagerly; an ambient ORCA_ENVIRONMENT stays
lazy, because failing local-only commands over stale background config would be
a regression.

`project setup-create` records independent metadata and, unlike the other setup
paths, is not covered by the runtime's ssh rejection — so an unknown target
persisted a row pointing at a machine that does not exist. It now resolves the
host. `local` and `runtime:` still pass through untouched: this is also the
provisioning path, where a runtime host legitimately may not exist yet when its
metadata is written.

`setup-existing-folder` and `setup-clone` deliberately keep the unresolved id.
The runtime rejects every ssh host for those operations regardless of whether it
exists, so resolving first would answer "no such target" and imply the command
would have worked with the right id.

* fix(cli): refuse an ambiguous host name instead of resolving the first match

Name lookup took the first match while the environment store itself refuses an
ambiguous name rather than guessing. That put the guess back, in the selector
whose entire purpose is to stop a command reaching a machine the caller did not
choose — and it applied to both spellings: two SSH targets sharing a label, and
two paired servers sharing a name.

Both now resolve to nothing and report every candidate with its id, so the
caller picks. An exact id still resolves past a colliding name, since an id is
never ambiguous.

Also pins the property that makes accepting a name safe at all: `runtime:<id>`
is a persisted token that lands in ProjectHostSetup.hostId and is embedded in
generated setup ids, so the name is canonicalized to the id before anything
downstream sees it. A test now asserts a name never reaches the wire.

* fix(cli): fall back to the older ssh listing so an old host is not read as having no targets

Hosts predating ssh.listTargetSummaries still answer ssh.listTargets, and both
are served by the same summariser. Swallowing the method_not_found made such a
host indistinguishable from one with no SSH targets registered, which would
reject a target id that is valid there — a new-client/old-host regression on a
path that previously passed the id through unvalidated.
2026-08-19 17:20:21 -07:00