Commit Graph
1139 Commits
Author SHA1 Message Date
Brennan Benson 384e6a9125 fix(workspaces): preserve cleanup browse compatibility 2026-08-10 17:15:32 -07:00
Brennan Benson a095af097f Merge remote-tracking branch 'origin/brennanb2025/workspace-cleanup-snapshots' into brennanb2025/review-pr-13406-filter-model
# Conflicts:
#	src/shared/workspace-cleanup.ts
2026-08-10 16:59:56 -07:00
Brennan Benson b6fffb7d7c fix(workspaces): scope snapshot persistence 2026-08-10 16:55:16 -07:00
Brennan Benson 3979debfa8 Merge remote-tracking branch 'origin/brennanb2025/worktree-inactive-filter-finder' into brennanb2025/review-pr-13405-snapshots 2026-08-10 16:36:56 -07:00
Brennan Benson b96db50eb1 fix(workspaces): preserve full scan semantics 2026-08-10 16:32:37 -07:00
Brennan Benson 630d6c0b86 feat(workspaces): add cleanup filter model 2026-08-09 17:55:17 -07:00
Brennan Benson 556031a028 feat(workspaces): persist cleanup snapshots 2026-08-09 17:52:47 -07:00
Brennan Benson 6de3999479 fix(workspaces): support full cleanup scans 2026-08-09 17:49:06 -07:00
a77002c42b feat(ai-vault): delete a provider session from the AI Vault list (#10249)
* feat(ai-vault): validate session-delete targets for single-file providers

Add the pure judgement layer for deleting an Agent Session History entry.
`validateAiVaultSessionDeleteTarget` decides whether a session may be removed:
the agent must be one of the nine providers where a single file is the whole
session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp),
the host must be local, and the renderer-supplied path must resolve inside
that agent's own session roots and match its discovery predicate.

To keep the delete roots from drifting from the scanner's own roots, the
WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw
root derivation + session predicate become shared helpers that
discoverOpenClawFiles itself consumes.

The result is path-only and never touches the filesystem; a returned
`allowed: true` still requires an lstat/realpath re-check in the executor
(S-2) before removal, documented as a caller contract on the result type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* feat(ai-vault): move a validated session transcript to the trash

Add the filesystem executor behind session deletion. It calls the S-1 path
validator, then performs the fs-side guards that validator documented it
could not: lstat().isFile() rejects a directory or symlink, and realpath is
re-fed through the validator so a regular file reached through a symlinked
parent that escapes the agent's roots is rejected too. Only then is the file
moved to the OS trash via shell.trashItem, with ENOENT treated as success so
a delete racing an external removal stays idempotent.

WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the
Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is
returned as a failure result rather than thrown, since IPC payloads are
untyped at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* feat(ai-vault): delete-session IPC handler, preload bridge, cache invalidation

Wire the S-2 delete executor to an IPC endpoint and expose it on the preload
bridge. The renderer calls aiVault:deleteSession with { agent, filePath,
executionHostId }; the handler fetches WSL homes, delegates to the executor
(which re-validates and trashes), and on a real delete invalidates the caches
that could otherwise keep serving the deleted session.

Cache invalidation is generation-guarded: a scan already in flight when the
delete lands carries an older generation and must not write its pre-delete
result back into the cache. Without this, an in-flight scan resolving just
after the delete would resurrect the deleted session for the 15s TTL — and
force-refreshing the panel only masks it for the desktop, not for the paired
mobile client or runtime RPC that share the same cache module. Both the shared
local-scope cache and the desktop multi-host cache carry the guard, with
regression tests for the in-flight race.

The delete result type moves to shared/ai-vault-types.ts so the renderer can
import the same contract the executor returns. To keep ai-vault.ts within the
max-lines budget after adding the delete wiring, two cohesive pieces are
extracted to their own files: the delete orchestration (ai-vault-delete.ts)
and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the
only handler with no dependency on this module's private cache state, so it is
the one piece that moves verbatim without threading state through a seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* feat(ai-vault): renderer judgement for whether Delete is offered

Add the renderer counterpart to the main-side delete validator: given a
session, decide whether the row menu shows Delete enabled, or disabled with a
reason a tooltip can render. It reuses the shared deletable-agent set and
unsupported-reason map so the two sides can never disagree about which agents
are deletable, and reuses the existing local-host / synthetic-path renderer
helpers.

This is intentionally not a security boundary — it validates neither the path
root nor the file predicate. Those are the main process's untrusted-input
defense; the renderer only picks the affordance, and the main side re-checks
on delete regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* docs(ai-vault): correct deletability parity claim; test multi-reason agent

The renderer deletability check runs host -> synthetic -> agent, while the
main validator runs agent -> host -> synthetic. The two layers agree only on
deletable-or-not (renderer-false is a subset of main-false), not on the reason
code a doubly-failing session carries. Document that explicitly instead of
implying the orders match, and add the antigravity case (two reason codes) so
the agentReasonCodes array shape is actually exercised.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* feat(ai-vault): add Delete to the session row menu with a confirmation dialog

Wire the delete affordance into AI Vault. Both the dropdown and the context
menu gain a destructive Delete item; a session that can't be completely deleted
(remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed
agent) shows the item disabled with a reason surfaced both as a tooltip and as
an aria-label so keyboard and screen-reader users learn why. Confirming opens a
dialog that names the session and states it will no longer be resumable from
the provider's own CLI, then calls the delete IPC and force-refreshes the list
for immediate feedback (the main side has already invalidated its caches).

The confirmation copy says the session "will be deleted" rather than "moved to
the trash": on Windows a WSL session is deleted with rm inside the distro (no
Recycle Bin), so promising recoverability would be a lie on that platform.

Deletability is computed once per row and shared by both menus so they can
never disagree. New pure logic — the reason-to-tooltip mapping (including the
multi-reason join) and the delete action hook's deleted/rejected/failed
branches — is covered by unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i

* fix(ai-vault): state that Delete is unavailable without naming the cause

The disabled Delete item explained a provider's storage layout to the user
("Claude sessions can't be deleted here: stores sessions as a folder, not a
single file"). That is Orca's problem, not the reader's — the tooltip now says
which sessions are affected and stops there. The non-local-host string stays as
it was: it states scope, not a cause, and tells the user what would work.

The reason-code plumbing existed only to compose that tooltip, so
AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the
renderer result's agentReasonCodes field go with it. Why each agent is excluded
moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking
up the deletable set will find it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7

* feat(ai-vault): delete claude, rovo, and grok sessions by their directory

These three were excluded only because the delete unit was one file. Their
sessions are directories — claude keeps Task subagent transcripts in a sibling
`<uuid>/subagents/`, rovo and grok keep everything under `<sessionId>/` — and
nothing in them is shared with another session, so a directory-aware delete is
still a complete delete. Supported goes from 9 agents to 12; the four that
remain (antigravity, kimi, codex, opencode) are blocked by a registry or a
SQLite row, which no delete unit fixes.

Validation now returns an ordered removal plan instead of a single path. Each
removal carries the kind it must be on disk and the roots its realpath must
stay inside, so the executor's guard is the same shape for a file and for a
directory. Companions come first and the transcript last: the transcript is
what puts the row on screen, so a part-way failure leaves the row to retry
from rather than dropping it and stranding the rest on disk.

Claude's `session-env/<uuid>/` goes with the transcript — it holds that
session's generated shell exports and nothing else. Its sibling
`file-history/<uuid>/` deliberately does not: it is the rewind buffer holding
earlier versions of the user's own files, and retiring a session is no reason
to take away the only copy that can restore them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7

* fix(ai-vault): remove a claude session's own directory, not just its subagents

Deleting a claude session trashed `<uuid>/subagents/` and left `<uuid>/` behind
as an empty directory — one per deleted session, accumulating under every
project. The directory is named after the transcript, so it belongs to that
session as a whole; take it rather than the one subdirectory inside it. Still
derived from the scanner's own subagents path, so the two cannot drift.

Reaching the parent means a degenerate stem now matters: `..jsonl` passes the
extension check and its stem is `.`, which would resolve the session directory
to the project directory holding every session. Reject it instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7

* fix(ai-vault): keep a session row collapsed when a menu action is chosen

Radix portals the row's dropdown and context menus out of its DOM, but React
still bubbles their clicks back through the component tree, so every menu
selection also hit the row's own click handler and expanded it. The trigger
button already stopped propagation, which is why opening the menu looked fine
and only choosing an item misbehaved.

It shows worst on Delete: the row expands behind the confirm dialog, so
cancelling leaves the list rearranged under a dialog the user just backed out
of. Toggle details only for clicks that land in the row's own subtree — that
covers the context menu and any future portalled surface, not just this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7

* fix(ai-vault): harden the delete-confirmation flow against IPC rejection and mid-delete dismissal

Two robustness gaps flagged in review:

- handleConfirmDelete only branched on result.outcome. The main handler
  resolves with a 'failed'/'rejected' outcome rather than throwing, but the
  IPC invoke itself can still reject on a transport/serialization error, and
  the caller fires it with `void`. That reject would surface as an unhandled
  rejection with no toast. Catch it and show the same generic failure toast.

- handleDialogOpenChange cleared sessionPendingDelete on every open=false.
  The Cancel button is disabled mid-delete, but Radix still fires its
  Escape/outside-click/X close, which could dismiss an in-flight delete out
  from under itself. Ignore close requests while deletingSession is true.

Both covered by regression tests (verified failing without the fix).

* fix(ai-vault): route WSL UNC directory removals through the WSL rm branch

Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's
session dir) gated the WSL branch on kind === 'file', so on Windows a session
under a WSL distro home fell through to shell.trashItem — which can't trash a
WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when
the 9P filesystem's unreliable lstat false-reports ENOENT and the executor
treats that as success. Single-file deletes predate the directory kinds, so the
file-only gate was correct until directory removals were added.

tryDeleteWslUncPath already supports recursive removal; pass recursive for
directory removals so they take the same WSL rm path as files instead of
shell.trashItem. Covered by two regression tests (file: non-recursive,
directory: recursive), verified failing without the fix.

Also drops the internal ledger-ID references (D-*, S-*) from comments in these
two files; they pointed at a private design doc a reader can't see.

* docs(ai-vault): drop internal design-ledger IDs from shipped comments

Comments across the session-delete feature cited decision/slice IDs (D-1..D-7,
S-1..S-5) from a private design document. Those references are meaningless to
anyone reading the code without that doc, so remove the IDs while keeping the
reasoning each comment carried. No behavior change.

* test(ai-vault): e2e-cover the real on-disk session delete

The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC
path actually removes files. This spec seeds sessions into the E2E harness's
isolated HOME and deletes them through window.api.aiVault.deleteSession:

- a single-file session (gemini): the transcript is gone from disk and drops
  out of the list.
- a directory-shaped session (claude): the transcript, the <uuid>/ session
  directory (subagents included, no empty shell left), and the session-env
  companion are all gone, while the file-history rewind buffer is preserved.

Verified failing when the executor's removal is stubbed out. Runs on Linux CI.

* fix(ai-vault): address review findings on the session-delete flow

Three points raised in review:

- Disable Delete for a still-running session. resolveAiVaultSessionDeletability
  now gates on liveState (working/blocked/waiting) last — an otherwise-deletable
  session that is mid-run shows "wait for it to finish" instead of an enabled
  Delete, so trashing a live agent's transcript can't drop writes it is still
  appending. Unsupported/remote sessions keep their permanent reason.

- Realpath the roots, not just the target, in the executor's escape check. The
  roots were only resolve()'d (text), so a session under a symlinked root
  (~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling
  back to its text form when it can't be resolved) before the membership check.

- Invalidate the parse cache with the raw filePath, not resolve(filePath). The
  cache is keyed by the exact path the scanner discovered, so resolve() could
  normalise it away from the stored key and miss. Drops the now-unused import.

Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the
upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion
domain module they belong to, and updates importers.

Regression tests added for the live gate, the symlinked-root accept, and the
reason string; verified failing without each fix.

* fix(ai-vault): type the deleteSession preload bridge as its real result

The bridge declared Promise<unknown> while AiVaultApi.deleteSession promises
AiVaultDeleteSessionResult, so the preload object leaned on the api-types
declaration to stay honest instead of being checked against it.

Co-authored-by: Orca <help@stably.ai>

* refactor(ai-vault): tighten the session-delete code to house style

Comments across the delete flow explained HOW alongside WHY and ran to a dozen
lines; they now carry only the non-obvious reasoning. The excluded-agent
rationale, the caller contract on the validator, and the file-history carve-out
are kept — those are knowledge, not narration.

Also removes three duplications the feature introduced:
- AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult
  whose comment pointed at a module the type no longer lives in.
- The synthetic-path predicate existed twice under near-identical names; the
  renderer now re-exports the shared one it already had a sibling import of.
- The delete-failure toast was written out verbatim in both the rejected and
  the thrown branch.

Co-authored-by: Orca <help@stably.ai>

* refactor(ai-vault): use a design-system dialog width and a stable row selector

The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in
the right sidebar uses a scale token, and md (448px) covers the role.

The row-expand test selected the row by [draggable="true"], which stopped
naming the row when draggable moved to the title element upstream. It still
passed by bubbling, so the comment was the only thing wrong — now it selects
the title deliberately and says why the query is first-match (Radix's asChild
trigger repeats the subtree, so screen.get* sees duplicates).

Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a
hand-written { outcome: string }, now that the preload bridge returns it.

Co-authored-by: Orca <help@stably.ai>

* refactor(ai-vault): consolidate agent sources and use system dialog

Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-07 11:47:26 -07:00
Jinwoo HongandJinwoo-H ddf58d6d6a fix(terminal): restore preserved remote PTYs after host relaunch (#12990)
* fix(terminal): foreground preserved daemon PTYs

* fix(terminal): keep snapshot sequence domains distinct

* test(terminal): use active reconnect control

* test(terminal): await reconnect control activation

* test(terminal): validate reconnect with fresh control

* test(terminal): tighten host restart evidence

* fix(terminal): retry preserved PTY attach after inventory

* fix(terminal): retry attach after overlapping inventory

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-08-07 01:25:18 -07:00
4b2603420e fix(terminal): protect local ConPTY Ctrl+Enter without breaking TUIs (#12462)
* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated kitty pane

Ctrl+Enter emitted \x1b[13;5u unconditionally, so a pane that never
negotiated the kitty keyboard protocol (local Windows ConPTY, plain
shell) printed the escape verbatim into the prompt. Mirror the
Shift+Enter guard and fall back to the legacy CR every emulator sends
for this chord. Keeps the intercept, so IME commit ordering and the
single-send dedupe still apply.

Fixes #12329

Co-authored-by: Orca <help@stably.ai>

* test(e2e): negotiate kitty via PTY output in the Ctrl+Enter spec

The Ctrl+Enter gate reads the PTY-output kitty tracker, which
enableKittyKeyboardReporting never feeds (it writes straight into
xterm's parser), so the spec pressed the chord on a pane the policy
still saw as un-negotiated and got the CR fallback. Negotiate from the
application side like the neighbouring Shift+Enter spec, and reset the
flags afterwards for the serial suite.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): preserve trusted Ctrl+Enter routing

* fix(terminal): scope IME redispatch ownership

* fix(terminal): reject conflicting Ctrl+Enter evidence

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-06 20:07:55 -07:00
Jinjing cb960408f2 fix(mobile): never auto-advertise virtual bridge addresses for pairing (#12962)
* fix(mobile): never auto-advertise virtual bridge addresses for pairing

Container/VM bridges stay manually pickable, but automatic defaults skip them so
QR codes do not race an unreachable direct path. Relay pairs without a local
address; LAN-only and runtime pairing fail closed on bridge-only hosts.

* fix(mobile): never auto-advertise virtual bridge addresses for pairing

- Set endpoint to null when no direct address is advertised, so the QR
  doesn't show an unreachable address to the scanning phone
- Distinguish "No address selected" (bridge exists but not advertised)
  from "No interfaces found" (genuinely nothing to pick)
- Add tests for NetworkInterfacePicker placeholder behavior
2026-08-06 19:17:43 -07:00
Brennan Benson b9e9811924 fix(permissions): use standard macOS Local Network request flow (STA-3505) (#12833)
* fix(permissions): surface macOS silent Local Network denial with diagnostic and workaround (STA-3505)

On macOS 27 beta, NECP silently denies Orca's whole process tree Local
Network access: no prompt fires, the app never appears in System
Settings, and terminal child processes fail with EHOSTUNREACH. The
Settings trigger swallowed the probe's socket error and reported
'unknown' + a 'Permission request sent' toast, indistinguishable from
success.

Classify the mDNS probe outcome (EHOSTUNREACH/EHOSTDOWN -> denied,
clean send -> granted, else unknown), remember the verdict for the
status chip, and render an inline diagnostic with the documented
NECP re-evaluation workaround when denial is detected.

* fix(permissions): avoid false Local Network grants

* fix(permissions): use standard Local Network request flow

* feat(permissions): add local network connection test

* fix(permissions): nest local network connection test

* fix(permissions): collapse connection test by default

* fix(permissions): emphasize connection test action

* fix(permissions): restore outlined connection action
2026-08-06 14:10:26 -07:00
Brennan Benson 92f759bb51 perf: bound repeated watcher, Session History, and terminal work (#12828)
* perf: bound repeated watcher, vault, and terminal work

* fix(terminal): preserve redraw recovery while bounding fit retries

* fix(watcher): retain structural fallback after crash fuse

* fix(ai-vault): preserve forced scan budget
2026-08-06 10:37:52 -07:00
b5ad2ac163 fix(ai-vault): group OMP task subagent transcripts under their parent session (#12803)
* fix(ai-vault): group OMP task subagent transcripts under their parent session

OMP persists task-child transcripts inside the parent session's same-named
artifact directory (<stamp>_<uuid>/), and discovery scanned them as ordinary
top-level sessions - a coordinator's history drowned under its own workers.

Extend the existing Claude subagent model to OMP, classifying purely by the
artifact-dir layout (never by a transcript's parentSession field, which also
describes non-task lineage):

- prune artifact dirs from the top-level scan (name-pattern predicate)
- count direct-child transcripts onto the parent row (local readdir; remote
  walks partition their listing instead, mirroring Claude's SSH posture)
- list children on demand via the existing listSubagentSessions IPC, titled
  by their coordinator-given task label and linked to the layout-derived
  parent id
- refresh the count on zero-turn cache reuse, matching Claude
- extract session-scanner-roots.ts so the renderer-supplied-path allowlist
  for both agents lives in one module

Fixes #9330

* review: harden OMP subagent classification and cover its uncovered branches

Prune predicate now skips depth 0 (the workspace dir), so a workspace whose
name happens to look like a session stem keeps its sessions. Drop degenerate
OMP roots in ompSessionsRootDirs: OMP_CODING_AGENT_DIR='/' normalizes to '',
which resolve()s to the process cwd and would have allowlisted it for the
renderer-supplied subagent path.

Rename session-scanner-omp-subagents.ts to -omp-subagent-transcripts.ts so it
mirrors Claude's transcripts/lister split by role rather than inverting it.

Correct two comments that asserted things the codebase contradicts: OMP task
children do carry their own sessionId and would resume by path (OMP's own
picker globs `*/*.jsonl`, so it never offers them either), and workspace dir
names are not uniformly dash-prefixed.

Cover branches the change added with no test: the remote/SSH partition wiring,
the IPC `omp` gate and per-agent allowlist, the parse-cache zero-turn recount,
and the executionHostId disk-ownership guard. Extract the remote scanner's
in-memory provider into a fixtures module to stay under the max-lines cap.

* review: note why child rows carry an unrendered grandchild count

* review: describe the real OMP grandchild layout in the pattern comment

---------

Co-authored-by: Dan Cieslak <dcieslak19973@users.noreply.github.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-08-06 01:29:19 -07:00
Jinjing c3db2b89fc Demote address picker to optional disclosure on Orca Relay (#12796)
* refactor(mobile): demote address picker to optional disclosure on Relay

Relay provides remote access without requiring a specific local address,
so hide the picker behind a disclosure to keep the direct fast path
accessible without visual clutter. Reposition Sign in between the Relay
and LAN options to clarify it's Relay-specific. Keep custom addresses
always visible and force the disclosure open when settings search
targets the address picker.

* refactor(mobile): improve relay pairing guide and interface ranking

- Rank Docker/VirtualBox bridges below real LAN addresses so they're never auto-advertised as the default
- Clarify UI copy: 'Local network address (optional)' → 'Direct connection on this network'
- Better explain direct connection vs Relay roles and when each is used
- Fix Relay unavailability to be a build property, not dependent on current selection

* refactor(mobile): reframe local network address as optional in relay pai

Demote the address picker from primary action styling to an optional
disclosure with quieter visual treatment. Update messaging from "Direct
connection on this network" to "Also use a faster local path" to
clarify Relay is the default path and local addressing only applies
when nearby. Add explanatory hint text to set expectations that Relay
remains available when away.
2026-08-06 00:34:42 -07:00
Jinjing 1251e5530f Show SSH worktrees immediately via persisted metadata (#12799)
* Retire SSH worktree metadata an authoritative scan proved gone

The metadata fallback's protection against resurrecting externally deleted
worktrees lived only in renderer module state, so it died on every reload
while the SSH WorktreeMeta it guarded against persists forever
(gcStaleWorktreeMeta exempts any repo with a connectionId, because a local
existsSync cannot probe a remote path). Repro: `git worktree remove` on the
SSH host, let the authoritative scan purge the row, restart — the startup
fetch runs before SSH connects and the fallback re-lists the deleted
worktree as a ghost row.

Chose option (a), deleting the stale persisted meta in main, over persisting
the removal memory: the metadata is the thing that outlives the worktree, and
Orca's own removals already delete it (removeWorktreeMetadataAndTransientState),
so external removals now converge on the same end state instead of accumulating
a second, parallel tombstone list that would itself need eviction. The
in-session memory stays for the window before the async delete lands.

New `worktrees:forgetRemovedForExecutionHost` only accepts SSH hosts, requires
an exact repo owner, skips metas owned by another host, and refuses folder
repos — a folder workspace's meta IS the workspace record (gcStaleWorktreeMeta
skips those keys for the same reason) and no remote scan can retire one. The
renderer only calls it from the authoritative-removal path, so a mere
disconnect never deletes anything.

Also:
- hoist resetAuthoritativelyRemovedWorktreeMemoryForTests into a top-level
  beforeEach; removeWorktree writes that memory too, so suppression could leak
  across describes and silently hide a row.
- cover the requireAuthoritative gate that skips the fallback, which had no test.
- replace the raw NUL byte committed inside the coalesce-key template literal
  with a \0 escape; it made the file scan as binary to grep/ripgrep.

* test(worktrees): verify non-authoritative fallback skips removal

The non-authoritative fallback must not trigger worktree cleanup when it observes an absence — only an authoritative scan should. Tighten the expectation to ensure cleanup happens exactly once, when new data arrives after the connection state changes.
2026-08-06 00:33:12 -07:00
Brennan Benson a2d438db17 fix(daemon): detect severed macOS TCC attribution behind terminal automation denials (STA-3491) (#12848)
* fix(daemon): detect severed macOS TCC attribution and surface daemon-restart remedy (STA-3491)

macOS pins the detached PTY daemon's TCC responsible process to the app
binary that forked it. Once that binary is deleted (packaged updates
replace the bundle), Accessibility/Automation grants on Orca silently
stop covering every daemon-hosted terminal: osascript/System Events
fails with -25211 no matter what the user grants.

- record spawnerExecPath in the daemon pid file at fork
- adoption checks it: severed + 0 live sessions -> replace the daemon
  (reason severed_tcc_attribution); live sessions are preserved
- Settings (Developer Permissions + Manage Sessions) show a visible
  banner pointing at Manage Sessions -> Restart while severed

* fix(daemon): harden TCC attribution recovery
2026-08-05 23:54:30 -07:00
Brennan Benson a30e3b9f61 feat(dashboard): add experimental agent map view (#12168)
* feat(dashboard): add experimental agent map view

* fix(dashboard): harden agent map behavior

* fix(dashboard): harden agent map recovery

* fix(dashboard): close map selection on view change

* fix(agent-map): center sparse layouts

* fix(agent-map): align completion and workspace actions

* Polish agent map interactions and repo labels

* feat(agent-map): add worktree lineage and project actions

* fix(agent-map): use marker for unread agents

* fix(agent-map): compact orchestrated families

* fix(dashboard): harden agent map actions and layout

* fix(agent-map): bound layout work and preserve interactions

* fix(agent-map): move unread marker to ring top-right

* fix(agent-map): seat unread marker on the ring's top-left edge

* feat(agent-map): restore the agent launcher and declutter map labels

Three gaps in the experimental Agent Map:

- The "start a new agent" picker was split onto a preserved branch during the
  08-02 rebase (47829cb226) and never re-landed. Restores that commit and its
  pop-out IPC, keyed on the raw worktree id rather than the map identity.
- Workspace labels draw at a fixed screen size with no collision handling, so a
  zoomed-out map stacked dozens of names on each other. Adds a declutter pass
  that seats project names first, then workspace names by attention, then
  project counts in whatever room is left.
- The pop-out had no workspace right-click at all: its renderer has no store, so
  the shared sidebar menu cannot mount there. Adds a snapshot-driven menu with
  the launcher and Sleep, relayed to the main renderer.

* refactor(agent-map): fold the map's filter rail into the shared toolbar filter

The rail duplicated the toolbar's project filter and cost the canvas 14rem of
width on the surface that needs it most. Agent states move into the toolbar's
Filter dropdown (map view only — the board's columns already separate them) and
count toward its badge; project filtering falls back to the toolbar's own. Show
all is the dropdown's Clear all, and Fit already lives in the viewport controls.

* fix(agent-map): isolate map work from main renderer

* perf(agent-map): stream status updates to popout

* fix(i18n): add agent map catalog entries

* feat(agent-map): glow working entities

* fix(agent-map): prioritize attention ring status

* fix(agent-map): distinguish subagent connectors
2026-08-05 22:57:16 -07:00
Brennan Benson 81e7a2ddf7 fix(terminal): recover live panes after renderer restart (STA-3536) (#12776)
* fix(terminal): stop transient probe blips from erroring restored panes (STA-3536)

terminal_pane_owner_unverified fired for every restored pane whenever one
liveness probe answer went missing: a cold-start daemon draining an attach
stampede misses the 2s getSize deadline, and a wedged superseded daemon
(protocol upgrades leave them running) turns every unmapped fan-out probe
null forever.

- probePtyOwners now skips legacy daemons whose startup inventory listing
  succeeded: fresh sessions never route to them, so they provably don't own
  an unmapped id and one wedged zombie can't poison every pane's verdict.
- attachStablePaneOwner retries the probe over a short backoff ladder before
  surfacing unverified, so a single missed deadline resolves to a verdict.
- The renderer replaces the raw error code with actionable copy.

* fix(terminal): stop retrying definitive owner probes

* fix(terminal): recover live panes after renderer restart

* refactor(terminal): share owner resolution abort guard
2026-08-05 20:52:22 -07:00
Brennan Benson 6df8997c3c fix(file-explorer): sort numbered file names naturally across every listing surface (#11576)
* fix(file-explorer): sort numbered file names naturally

The File Explorer compared names with bare localeCompare, so numbered
files listed 100, 200 before 99. Hoist the numeric collator Source
Control file rows already use (#10850) into src/shared and apply it to
the local and runtime directory listings, the name-filtered view, and
Source Control directory nodes, which were inconsistent with the file
rows one line below (#11426).

* fix(file-explorer): natural sort on SSH funnels, relay, and pickers

Adversarial-review round 1 rework:
- Both readDir funnels short-circuited to the SSH filesystem provider
  before the patched sort, so SSH workspaces kept lexicographic order;
  re-sort locally after the provider returns (the remote relay may be an
  older build), and fix the relay's own comparator for relay-native
  consumers.
- sortDirEntries (shared, unit-tested) owns the directories-first +
  natural-order listing contract used by every funnel.
- compareFileNames breaks numeric-collation ties ('2' vs '02') by code
  units so sibling order stays total instead of readdir order, and pins
  the collator locale to 'en' so every host produces one order.
- The SSH folder browser and runtime server dir picker now match the
  Explorer they browse into.
- Ordering pinned by tests at the relay, source-control tree, and shared
  helper.

* fix(mobile): natural sort in the mobile file explorer

Mobile re-sorted host readDir results with bare localeCompare, undoing
the host funnel's natural order (round-2 review). Reuse the shared
comparator and pin the order in the mobile suite.

* fix(file-explorer): natural sort at the renderer choke point and remaining ties

Round-3 review: the remote-runtime RPC and paired-web routes return the
host's order verbatim, so re-sort in readFileExplorerDirectory where
every desktop route converges; pin the SSH funnel with a handler-level
test; and route Source Control path compares through compareFileNames so
numeric-collation ties share one total order with the Explorer.

* docs(file-name-sort): state the real perf baseline in the hoist comment

* refactor(source-control): drop the dead collator export; pin the test oracle locale

* fix(file-listings): cover remaining natural-sort surfaces
2026-08-05 17:11:28 -07:00
Jinjing 2ff2a1b268 Display SSH worktrees immediately using persisted metadata (#12646)
* Display SSH worktrees immediately using persisted metadata

Users can now see known worktrees for SSH hosts without waiting for the
provider connection to establish. Worktrees are fetched from local metadata
and displayed as non-authoritative, then merged without replacing richer
live data once the provider becomes available.

* Show SSH folder workspaces immediately via persisted metadata

Add safeguards for metadata fallback: track authoritatively removed
worktrees per host to prevent resurrection, position new rows within
the host block to avoid jumping on authoritative scan arrival, and
preserve co-owner detection status during merge. Coalesce concurrent
metadata fetches to dedupe overlapping queries.
2026-08-05 16:52:49 -07:00
Jinjing debf4affe7 Display total lines of code change in branch header (#12771)
* Add branch line total chip to source control header

Display the total lines added and removed across a branch from its fork point, measured via `git diff <mergeBase>`. Only computed when the chip is visible (request gate on merge base OID), with 500ms soft deadline to protect status latency and 15s hard timeout. Deduplicated across concurrent pollers and cached alongside line stats. Omitted on failure — always shows exact or nothing, never a partial estimate. Updates throughout the stack: native git status, relay, renderer store/API, and UI components.

* Pin branch line total to app locale

Format line counts using the app's configured locale instead of the system
locale, ensuring consistent cross-platform display and test reliability.

* test: wait for coalescer joins instead of fixed sleep

Hold the diff until the second status pass actually takes the
branch-total coalescer lease instead of using a fixed 400ms sleep.
Fixes timing-dependent flakiness on slow machines.
2026-08-05 14:46:00 -07:00
Brennan Benson 4c49989c2e refactor(codex): delete the unreachable managed shared-mirror lane (#12614)
PR 9501 shipped real-home routing for the host system default, and the
env override that could turn it back off was never a shipped control. The
managed-account half of the shared runtime mirror has been unreachable
since: every host account routes to its own self-contained CODEX_HOME
before that code runs.

Delete the flag module and its env plumbing plus the managed branch of
syncForCurrentSelection and the six helpers only it called. The three
lanes that still use the shared mirror -- Windows, a custom CODEX_HOME,
and a hook-lane gate that reports unusable -- are untouched, as are every
legacy migration and the WSL read-back helpers.
2026-08-05 12:57:02 -07:00
Brennan Benson de64337c26 fix(worktree-watcher): refresh status after external pushes (#12361)
* fix(worktree-watcher): surface external push -u through the git-common watch

An external-shell 'git push -u' writes only the common .git/config (plus
refs/remotes/<remote>/<branch>), both invisible to the git-common event
filter, so the Checks panel stayed on 'No upstream configured' until the
renderer safety poll. Classify the common config and remote-tracking refs
as status-tier signals, poll config alongside the other primary-checkout
metadata files, and keep FETCH_HEAD/reflog/ref-lock churn ignored.

* fix(worktree-watcher): refresh after subsequent pushes
2026-08-05 11:26:13 -07:00
Jinwoo Hong eea0bb64db fix(ssh): make PTY owner admission explicit and non-destructive (#12673)
An owner-capable `pty.openClient` had two failure modes that presented as something else.

If the relay still held an owner record but the request carried no matching resume proof, admission fell through to a SUBSCRIBER grant — a success-shaped response the client cannot use, which it then rejected as "did not grant an authenticated PTY session owner". And if the relay had forgotten the record the client named, admission threw a stale-recovery error, which the client answered by deleting its own recovery row — `clientInstanceId` included — and reopening. Two round trips, and the identity that lets it resume that target at all went with the deletion.

Now every owner grant carries a required `resumed` flag, a forgotten record mints a fresh claim in one round trip, a held claim returns one of three coded refusals, duplicate opens on one connection are rejected even when identical, and an attached-holder refusal becomes a typed error routed through the terminal-relay-error callback instead of feeding redeploy backoff a link that is working fine.

Independent review caught two regressions in the first attempt, both now fixed and both with tests that fail without them:

**A backpressure teardown could take a live owner's session.** The safety argument was that a record only becomes `disconnected` from an observed peer close — but two of the six paths there are capacity paths, where the relay destroys the client's socket itself because its lane queue filled. That is the signature of a client that is ALIVE but not draining fast enough. Demonstrated: the real owner is torn down for backpressure, a rival is granted ownership 270ms into a nominal 30s grace, and the owner's later reconnect with a valid resume proof is refused permanently, backoff cleared, no retry. Closes now carry a cause (`peer-closed` | `local`, defaulting to `local`, which only ever widens a grace), and the floor applies only to closes the transport actually observed on the peer's side. Capacity teardowns, decode faults and sink failures keep the default.

**A client's own zombie connection blocked it permanently.** Only `SshRelaySession` ever requests owner, and every endpoint-credential client shares one principal — so in a normal single-app deployment an `active` incumbent refusing you is almost always your own half-open connection the relay never saw close. That was refused as terminal, where main recovered on bounded backoff once keepalive noticed. The refusal already held both client identities; a match is now a distinct transient refusal that falls through to relay-lost backoff, restoring that recovery. A genuinely different client is still blocked.

Also: each retry deadline now starts when its own phase begins, instead of both being computed at entry where a slow first phase could leave the second with zero attempts.

Fixes STA-3365.
2026-08-05 02:56:07 -07:00
Jinwoo Hong cc1859d61c fix(ssh): snapshot detached SSH leases on quit and bound the teardown (#12687)
Per-target SSH teardown awaited `removeAllForwards` BEFORE anything marked the lease detached, so a slow forward close let the final store flush snapshot while leases still said `attached` — and the later durable write was rejected because persistence had already finalized. On the next launch those leases described a state that never existed.

`beginSshShutdown()` now performs every in-memory transition synchronously before returning, and the quit path calls it immediately before `store.flushAsync()` with no await between. The whole drain shares one deadline that REPORTS unfinished `{targetId, phase}` rather than concluding anything about it, and `waitForSystemSshForwardStop` gained a post-SIGKILL bound.

Nothing here destroys a session. `detached` means this app let go of the lease, not that the shell died — the pre-pass exists precisely so still-running PTYs are recorded as detached-but-alive instead of being lost to an `attached` snapshot. Review confirmed every reader honors that: reattach enumeration and persistence restore filter only `terminated`/`expired`, lease normalization has no age-based expiry, and attempt exhaustion leaves a lease alone. The drain deadline's only consumers are a warning log and a join that discards the value — nothing reads it as "gone".

Review also caught a defect the refactor introduced, now fixed: making the pre-pass synchronous meant a throw from `beginShutdownDetach` — via `webContents.send` on a renderer that quit had already destroyed — escaped the non-async `will-quit` listener and skipped `killAllPty()`, the watchers, `store.flushAsync()`, the teardown barrier and `app.quit()`. That would have lost the exact snapshot this PR exists to make correct. Each call is now wrapped per session, collecting errors and continuing. Proven: the test throws from the first of two sessions and fails without the fix with "Object has been destroyed".

A second test could only fail via timeout rather than assertion; the ordering is corrected so removing the post-SIGKILL bound now fails in 5ms with a clean assertion instead of a 5s timeout.

Rebased onto main and verified independent of #12673 (zero references to its owner-admission changes), which is being reworked separately. Fixes STA-3366.
2026-08-05 02:54:29 -07:00
Jinwoo Hong d15939c5fd fix(terminal): recover rejected paired-runtime input (STA-2830) (#12675)
With a desktop client paired to a remote Orca runtime, terminal panes could report connected, writable, and `terminal.send` returning accepted — yet keystrokes never reached the agent. No error, no banner, no recovery; input silently vanished.

The ticket was really two bugs. The attach half was already fixed by #12589 (subscriber-driven daemon attach), confirmed by reproducing against current main. This fixes the remaining half: a write the host refuses had no way to tell anyone.

A capability-negotiated `WriteUnavailable` opcode carries that refusal back to the client, where it feeds the pane's pre-existing recovery hook. Capability gating matters because decoders reject unknown opcodes on desktop — and, worse, silently drop them on mobile — so the signal is negotiated in the subscribe handshake. Verified per direction: an old host strips the unknown Subscribe key, an old client omits it so the host never emits, and capability cannot be inherited across resubscribe.

Independent review then found the signal was being delivered and discarded: recovery demanded an authoritative liveness answer, and `pty:hasPty` had no `remote:` guard, so a paired pane's id fell through to the LOCAL provider, which returned false, and recovery bailed before remounting. Every test stopped at the transport boundary, so all of them passed while the pane stayed just as stuck. `pty:kill` already had exactly that guard.

The fix makes main answer LESS rather than claim more: `pty:hasPty` now returns unknown for a `remote:` id instead of a fabricated false, because main cannot speak for another host's PTY. The remount is then authorized by positive evidence — the process that owns the PTY stating it refused this specific write over a live negotiated connection — not by inference from silence. Local and app-SSH ids keep the probe, where a false genuinely means the shell died. Nothing is destroyed on this path; the remount rebuilds the renderer over the session it already had.

An end-to-end test now carries a rejected write from the host through to an actual remount, which no prior test did. A surviving mutant was also killed: the legacy-binary capability gate could previously be deleted with nothing turning red.

The reliability gate stays experimental — live paired journeys and mixed installed-release evidence remain uncollected. Fixes STA-2830.
2026-08-05 01:41:27 -07:00
Jinwoo Hong 15ef69a814 refactor(runtime): preserve transport error codes over IPC (#12667)
Errors thrown across Electron's `ipcMain.handle` lose their structured error code — only the message survives. So the renderer classified transport failures by matching substrings of English message text. That is how a queue-overload rejection escaped classification during a remote outage and surfaced as a raw error wall: the code was stripped in transit and its message fragment was not in the recoverable list.

This converts `RemoteRuntimeClientError` and `RuntimeRpcCallQueueOverloadError` rejections from `runtimeEnvironments:call` into the existing structured `{ok:false, error:{code,message}}` response, which the preload already passes through unchanged and `unwrapRuntimeRpcResult` already reconstructs with the code intact. Classification now treats a present code as authoritative and consults message fragments only when there is no code.

The fragment list is deliberately RETAINED as a backstop, not deleted: untyped main-handler rejections, subscription-start failures, and older code-less paths still rely on it.

Proven real rather than cosmetic: a test-only patch applied to unmodified main fails (4 failed / 66 passed) because the code does not survive the boundary today, and passes on this branch.

Independent review specifically chased the risk that a present-but-unrecognized code would now short-circuit to fatal where a message fragment previously rescued it — the shape that dead-ends a pane. It enumerated all 34 reachable code/message pairs and confirmed no pair flips recoverable to fatal, that the newly-serialized code set is closed and client-local, and that host-forwarded codes preserve recoverable classification by design. A differential harness over that corpus was verified non-vacuous by injecting the bad shape.

Nothing crosses the paired-runtime wire: desktop main -> IPC -> preload -> renderer only, reusing an existing response shape, no new fields or opcodes.

The connection-level offline state with a single reconnect affordance remains as STA-3456 follow-up work.
2026-08-05 00:44:05 -07:00
ShawnandBrennan Benson c4d5a535f2 fix(pty): restore macOS TCC attribution persistence (#12562)
* fix(pty): restore macOS login shell attribution

Replace the env intermediary with a clean bash trampoline that execs the configured shell as a login shell. Keep SHELL, custom arguments, fallback shells, and unsafe characters positional.

* chore(pty): log macOS TCC spawn strategy

Record wrapped versus direct daemon PTY strategy without private values so live TCC reports can distinguish launch behavior.

* fix(pty): harden macOS TCC login trampoline

* test(pty): update macOS TCC spawn expectation

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-05 00:36:22 -07:00
Jinwoo Hong 1f86f980c5 fix(ssh): let terminate reach remote PTYs the app gave up reattaching (#12642)
Every SSH reattach failure abandons the remote terminal without shutting it down, and abandoned terminals then became structurally unreachable — excluded from reattach enumeration and, critically, filtered out of the user-facing "Terminate sessions" action, so a user could not kill them even manually.

The core problem is a naming trap: `expired` never meant the remote shell died. It means the app gave up reattaching. It is written on reattach failure, on spawn-time expiry, and in bulk by a relay reset inside a `finally` that runs even when the force-stop threw. So the leases most likely to name a still-live orphan were exactly the ones the terminate path excluded.

This change is reachability only. Expired leases are now reachable by an explicit user-initiated terminate, with the relay's response used as evidence: a shutdown that reports the PTY gone tombstones the lease, and leases already proven terminated are left alone.

**No automatic kills were added.** Every abandon path was enumerated and none of them proves abandonment: attempts-exhausted knows nothing (the relay never answered), identity mismatch means a *live* PTY belongs to a different pane so killing it would destroy someone else's terminal, and not-found is the one branch with real proof of death — where the process is already gone and needs no shutdown. Per the rule that unprovable liveness never authorizes destroying a session, the abandon paths deliberately leave the process running.

Relay-side automatic collection of unattached PTYs is deliberately NOT implemented: the relay cannot distinguish an abandoned terminal from a deliberately detached one, and the unlimited default grace exists precisely so long-running work survives disconnects and host sleep. Any bounded reaper would be killing on absence of evidence.

Verified: 3 tests red on main. Negative tests assert each abandon path leaves the shell running and the lease terminable, proven real by mutation — adding a shutdown to the exhausted branch or expiring on identity mismatch each turns them red.

Fixes STA-3376.
2026-08-04 23:07:34 -07:00
PLANDER DEV 7 cbc005c8aa fix(remote-runtime): materialize the host surface when reconnecting a terminal pane (#11542)
Reconnect could never recover a terminal pane whose host-side process was gone (host restarted, or the workspace was never opened there): recovery only polled the tab inventory, which can never create the surface it is waiting for, so Reconnect spun for ~60s and gave up permanently.

Verified with a deterministic reproduction: on main the recovery path issues 51 inventory polls and zero activations across both an automatic online trigger and a manual Reconnect click; with this change the pane re-materializes, rebinds and accepts input.

Review found and fixed three further defects beyond the original change:
- an activation answered with a stale ready handle left the loop polling forever instead of re-activating;
- a non-missing activation failure (e.g. an older host without the method) never fell back to inventory;
- host-side, activating a parked surface permanently deleted the host tab, because an already-absent persisted binding was read as a competing owner *after* the destructive retirement had already run.

Independent review confirmed by mutation testing that every production change is covered by a test that fails when it is reverted, that only an authoritative inventory can retire a pane, that the loop is bounded under every failure mode, and that the unknown-liveness guard (proven death required before retirement) is intact.

Fixes STA-3002.
2026-08-04 22:59:06 -07:00
Brennan Benson 9507cbce0c fix(terminal): rate-cap WebGL atlas recovery (#12622) 2026-08-04 20:42:11 -07:00
Jinjing fe72eeb75c Add linked issue guidance and ELI5 sections to PR generation prompts (#12613)
* Add linked issue guidance and ELI5 sections to PR generation prompts

Include linked GitHub issues in PR descriptions with Fixes/Refs guidance, and require ELI5 Problem and Solution sections before implementation details. Tests verify linked issue substitution and prompt structure enforcement.

* Include linked issue details in PR description generation

- Fetch the linked GitHub/GitLab issue title and body so generated PRs reference real issue context instead of just a number
- Use provider-specific reference syntax (Fixes/Refs, Closes/Related to, AB#) and label the issue by the active provider
- Feed issue title and description into the generation prompt while treating them as untrusted context, never as instructions
- Fall back to a cached work-item title when the provider lookup fails, and skip cross-provider issue attachment
2026-08-04 20:05:30 -07:00
Neil 5f187e0836 fix(renderer): break Activity and terminal React #185 loops (#12600)
Breaks two independent React #185 (Maximum update depth exceeded) crash loops.

- Activity portal publication is idempotent by descriptor value, so a semantic
  no-op no longer bounces synchronously through Terminal and back into Activity.
- The Activity readiness burst budget survives slot, target, pane, and tab
  retargeting, and a quiet loading pane is rechecked when the window expires.
- Terminal cold-parking pins verdict bursts to the safe mounted side before
  React reaches its nested-update limit, with an expiry so tabs can park again.

Supersedes #12492 and #12485.

Portal descriptor equality is keyed off keyof ActivityTerminalPortalTarget so a
new field fails the build instead of silently suppressing a publish. Park-verdict
damping and breadcrumbs gate on pin liveness, and churn crumbs coalesce by
trigger so a burst cannot collapse into a slow-churn slot.
2026-08-04 19:07:47 -07:00
Brennan Benson 0ce108d935 fix(browser): add native-UA session profiles (#12608)
* fix(browser): add native-UA session profiles

* test(browser): add Google sign-in UA probe

* fix(browser): preserve native profile UA identity
2026-08-04 19:07:23 -07:00
Brennan Benson dc5c5a89ba fix(codex): block launches with unproven runtime auth (#12490)
* fix(codex): block launches with unproven runtime auth

* fix(codex): reconcile shared auth before resume
2026-08-04 17:38:48 -07:00
Jinwoo HongandOrcaWin 72245918a1 fix(terminal): attach never-activated daemon sessions on remote subscribe and provider-read fallback (#12589)
* fix(terminal): attach never-activated daemon sessions on remote subscribe and provider-read fallback

A daemon-backed terminal whose tab was never activated in the host UI was
never attached, so the daemon emitted no bytes: paired clients rendered
blank/frozen panes and `terminal read` returned an empty tail while the PTY
was alive.

- Runtime: first remote view subscriber of a known-but-unattached local
  daemon session triggers an attach through the pty controller — attach-only,
  no resize, no renderer mount/focus, headless-safe, deduped across
  concurrent subscribers, and never detached on release. Excludes SSH-scoped
  ids and sessions a local spawn already published this generation.
- Read path: withVisibleSnapshotFallback now falls back to the provider tail
  for an empty-tail never-attached live local session; unprovable state stays
  empty, never an error.
- pty controller: expose attach with getProviderForPty-style routing,
  answering false on doubt; local daemon provider only.
- Daemon adapter: attach rides the session's applied size instead of a
  hardcoded 80x24, sends attachOnly, and retires a pre-v31 daemon's
  accidental spawn instead of publishing it.

Deterministic harness drives the real terminal.multiplex handler against a
real OrcaRuntimeService with an injected daemon-model controller whose data
events are gated on attach; covers snapshot-capable and snapshot-null
daemons, concurrency, release, replacement-spawn exclusion, and negative
safety. Red on base, green with the fix, red again with the fix reverted.

* fix(terminal): refuse degraded-provider attach fallback and surface failed legacy-spawn retire

Verifier follow-ups on subscriber-driven daemon attach:

- DegradedDaemonPtyProvider.attach routed unknown ids to the in-process
  fallback, whose no-op attach resolves — the runtime then pinned a
  subscriber-driven attach as succeeded while the stream stayed blank.
  Attach now refuses any route that resolves to the fallback (a fallback pty
  cannot own a daemon-surviving session), so the controller answers false,
  no sticky success is recorded, and a later subscriber attaches once a
  daemon adapter proves the id. Session-probe adoption moved to
  degraded-daemon-session-routing alongside the new refusal.
- The pre-v31 attach-only TOCTOU retire (accidental legacy spawn kill) now
  logs a warning with the sessionId on kill failure instead of swallowing
  it, so an orphaned replacement shell is diagnosable.

Regressions: degraded provider refuses unowned/fallback-owned attach and
routes to a daemon once it proves the id (red on previous commit); runtime
harness pins refused-attach retry for a later subscriber; adapter test pins
the surfaced kill failure.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 17:25:20 -07:00
Jinwoo HongandOrcaWin 51ca82d028 fix(runtime): seed terminal previews and titles from restore payloads (#12579)
* fix(terminal): seed list/read records from reattach restore payloads

After an app relaunch the PTY daemon survives and spawn silently
reattaches, but the restore payload (reattach snapshot, cold-restore
scrollback, relay replay, lastTitle) arrives as a spawn RPC result and
never passes through runtime.onPtyData — the only feeder of the terminal
records behind `terminal list`/`terminal read`. Every restart therefore
left connected terminals with empty title/preview/lastOutputAt and a
zero-line read tail, blinding orchestrators that poll terminals.

The spawn flow now calls runtime.seedTerminalRestoreTail with the restore
text and lastTitle, unconditionally of the renderer-authority emulator
gate (the records are main-side only). The seed reuses the live path's
normalize/tail/preview pipeline on a capped 256 KiB suffix (re-anchored
at a line boundary so a cut escape cannot leak), only fills records that
never saw output (a remount reattach cannot re-apply history), routes
titles through the applySeededAgentStatus precedent (state writes only —
no waiters, no side-effect facts), and never stamps lastOutputAt or
waitBlockedAt: restored bytes are historical, not fresh activity.

lastTitle is threaded from the daemon reattach snapshot and cold-restore
checkpoint into PtySpawnResult; relay replays seed preview only. SSH and
runtime-controller paths are unchanged — seeding is gated on the fields
existing.

* fix(terminal): seed restore records on the controller spawn path and prime the wait baseline

Follow-ups to the restore-record seed, from independent verification:

1. The runtime-controller spawn flow (createTerminal background creates —
   headless `orca serve`/CLI — and pane splits) never consumed restore
   payloads, so the exact orchestrator-blindness this fix targets survived
   on the topology that needs it most. The extraction now lives in one
   helper called from both spawn choke points (renderer pty:spawn and the
   controller flow); the runtime's empty-record guard makes overlapping
   seeds a no-op.

2. The throttled per-PTY wait scanner starts with a null baseline, so a
   permission prompt visible only in seeded HISTORY read as newly gained
   on the first benign live chunk and stamped waitBlockedAt "now".
   Seeding now primes the scanner baseline from the seeded tail without
   stamping; only a signal appearing in genuinely new output counts.

3. Cap re-anchoring accepts \r as well as \n (newline-free CR-redraw
   streams), consuming a full \r\n pair so the seed does not start with
   a phantom blank line.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 17:23:01 -07:00
Jinwoo HongandOrcaWin 3d8131d7ea fix(runtime): reject leaf terminal sends only on controller-proven PTY absence (#12578)
* fix(runtime): reject leaf terminal sends only on controller-proven PTY absence

orca terminal send to a leaf whose ptyId no provider in this process owns
was a silent no-op reported as success: the graph mirror answers
writable=true, every provider write to an unknown id is accepted
fire-and-forget, and bytesWritten is computed from the payload rather than
delivery. sendTerminal and sendTerminalAgentPrompt now consult a controller
liveness probe when the provider does not synchronously know the id
(hasPty), and throw terminal_not_writable only on an exact false — unknown
liveness, probe errors, SSH/remote scopes, and probe-less providers never
reject (#12393's rule: null is not absence), so a restored daemon session
still accepts writes before its pane remounts. Push-on-idle orchestration
delivery gains the same gate so a proven-dead leaf keeps its messages
queued instead of marking them delivered into a void.

The pty controller now exposes probePtyLiveness, routed like write: a
provider probe is preferred, the in-process local provider's refusal is
authoritative (sole owner), and remote-scoped or SSH ids without a probe
answer null after awaiting the cold-start daemon swap. Proven-absent
verdicts cache 15s per ptyId with in-flight dedupe, superseded the moment
the provider re-learns the id.

* fix(runtime): arm one probe-deferred delivery continuation per pty

Review (GPT verifier) confirmed: triggers arriving during one in-flight
absence probe each attached a continuation to the deduped probe promise, and
since Claude-target delivered_at stamps only after the delayed Enter, every
continuation re-read the same unread rows — double payload injection and two
armed Enters. Single-flight the deferred continuation per pty; the one armed
continuation re-reads fresh rows when it fires, so nothing is lost, and the
guard clears on settle so later triggers defer again. The narrower
pre-existing 500ms sync-path window is unchanged and out of scope.

* fix(runtime): single-flight the whole orchestration delivery window per pty

The probe-continuation guard cleared at probe settle, but Claude-target
delivered_at stamps only in the delayed-Enter callback ~500ms later — a
trigger landing in that gap armed a fresh probe cycle, re-read the same
un-stamped rows, and re-injected the payload. The identical window existed
on the pure sync path pre-PR (two triggers within 500ms double-deliver).

Hold a per-pty delivery-in-flight flag from before the payload write until
delivery settles: entry-checked before reading unread rows, cleared through
one settle point covering the failed write, the sync-stamped coordinator and
Cursor branches, any sync throw, and the delayed-Enter callback on submit,
refusal, and throw alike. A trigger arriving mid-flight is not dropped — it
parks the latest leaf per ptyId and re-runs delivery once on settle, so rows
inserted mid-flight deliver without waiting for the next idle event. The
probe single-flight stays; the new guard subsumes its post-settle gap, and
no trigger site bypasses it.

Both strengthened tests are red on the previous commit (first subject
injected twice) and green here: in-window re-trigger on the probe path and
sync-path double-trigger each deliver the first batch exactly once, with the
parked second row delivering alone after settle.

* fix(runtime): retire the armed delivery Enter on pty exit; guard fire-time on current state

Two variants of one root cause — the delayed-Enter callback outliving the
session it was armed for:

1. Cold restore respawns under the same session id. onPtyExit never
   cancelled the armed Enter or the in-flight delivery state, and
   onPtySpawned flips the same leaf writable again — so an exit + same-id
   respawn inside the 500ms window let the stale callback inject \r into
   the replacement session and stamp rows it never received, then settle
   against a newer same-id flight.
2. Graph resync replaces leaf objects, so onPtyExit flips writable=false
   only on the current replacement; a callback trusting its closed-over
   snapshot still read writable=true and fired after exit with no respawn.

The flight record now carries its armed Enter timer and serves as settle
identity: onPtyExit clears the timer and drops the flight and any parked
re-delivery without stamping (rows stay unstamped and re-deliver on the
replacement's next idle — the existing contract), and settle no-ops unless
its own flight is still current, so a stale settle can never clear a newer
same-id flight or flush its parked trigger. At fire time the callback
re-resolves the leaf by key and requires the same ptyId binding and current
writability instead of reading the closure snapshot.

All three regressions are red on the previous commit: same-id respawn saw
\r plus a false delivered_at stamp, exit leaked the flight and parked
state, and the orphaned-snapshot resync variant fired Enter after exit.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 17:22:10 -07:00
Brennan Benson 40ea4ece1a Track Claude models from the installed CLI per host (STA-3330) (#12369)
* feat(native-chat): track Claude models from the installed CLI per host (STA-3330)

The Claude seed no longer pins version labels to aliases that resolve
differently across CLI versions, and the catalog now defines listModels
backed by a one-shot list_models control request over --print stream-json.
Hosts whose CLI predates the request answer with a control error and keep
the seed. Discovery also feeds Source Control AI via the commit-message
spec, and the /model echo detector matches resolved model names.

* fix(native-chat): preserve discovered Claude capabilities

* fix(native-chat): tolerate malformed Claude model entries

* fix(native-chat): discover models in folder workspaces

* fix(native-chat): trust discovered Claude capabilities

* fix(native-chat): remove Claude model fallbacks

* fix(native-chat): keep the Claude model picker rendered

The Claude picker rendered nothing until the per-host `list_models` probe
returned, so it popped in ~1s after mount and never appeared at all when
the probe failed — an old CLI without `list_models`, no `claude` on PATH,
or an older remote runtime whose response omits `catalogOrigin`.

Restore the version-neutral family seed as the starting list; discovery
still replaces it wholesale on success, so a host with a real catalog
never shows an obsolete hardcoded row.

Separately, the tracked model could fall outside the active list: the
terminal header scrape yields family ids (`opus`) while a current CLI
lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and
dropped the model's effort and fast-mode controls. Reconcile the tracked
id into the active list once, so the snapshot, the appliers, and typed
command recording all see a labelled, operable row for it.
2026-08-04 15:47:29 -07:00
2548b816c0 Keep the app responsive when security software slows process creation (#12217)
* fix(ports): keep the app responsive when security software slows process creation

Orca ran the workspace port scan's probe commands (lsof/ps on macOS,
netstat + powershell.exe on Windows) directly in the Electron main process.
libuv performs process creation inline on the calling event loop, which in
the main process is the browser UI thread, so an endpoint-security module
hooking CreateProcessW froze the whole window for the length of the spawn.

The same stall also produced a false diagnosis: the 4s command watchdog was
armed before execFile (local-workspace-port-scanner.ts:389 -> :410), so its
deadline had already passed by the time the command started. Every scan on a
hooked host reported a command timeout, tripping the 60s -> 5min backoff and
the "Port scanning is temporarily paused after a command timeout" banner even
though the commands themselves were healthy.

Probe commands now run on a lazily created, unref'd worker thread with FIFO
one-at-a-time dispatch, and the watchdog is armed after execFile returns so it
measures the command rather than the spawn. Node's own execFile timeout kill
(killed: true) is classified as a command timeout, keeping the backoff working
for genuine hangs. A scan that observes a stalled spawn skips its optional
metadata commands for that cycle, capping a hooked-host scan at roughly one
stall instead of three.

Closes #11161

* fix(ports): keep advertised URLs when a stalled spawn skips port metadata

Review follow-up on #11161. The stalled-spawn early return handed
scanWorkspacePorts raw ports with no cwd/commandLine, so every port failed
attribution and reconcileAdvertisedUrls told the watcher each worktree's
listeners had vanished. shouldEvictAfterScan then deleted every cached
advertised URL and broadcast a removal event; those URLs are only ever
captured from live PTY output, so the dev-server link was gone until the
server restarted.

The scanners now report metadataAvailable, and reconciliation is skipped for
a scan that never gathered attribution evidence. The skip is also no longer
self-perpetuating: on an EDR-hooked host every spawn stalls, so gating purely
on the current scan's spawnMs made every port permanently external (Stop
refused with 'Only workspace-owned local processes can be stopped here.').
Metadata is now re-probed on the scan after a skip, matching what the comment
and test name already claimed.

Co-authored-by: Orca <help@stably.ai>

* test(windows): stop a temp-dir lock from failing the CLI launcher smoke test

The native launcher assertions passed on windows-latest, but teardown's
rmSync raced Windows' release of the image handle on the exe the test had
just executed and threw EPERM, failing the job.

Cleanup now retries and, on Windows only, tolerates a residual lock code
instead of reporting it as a launcher regression.

Co-authored-by: Orca <help@stably.ai>

* fix(ports): scope the metadata skip away from attribution-dependent scans

The metadata skip was a process-wide parity flag, so Stop and the
localhost-label allowlist could land on a degraded cycle and reject a
port the panel had just shown as workspace-owned. Give those callers an
explicit requireMetadata option, and carry the previous cycle's listener
metadata forward so a skipped background scan no longer republishes
workspace ports as external.

Also pin the watchdog ordering: the stall in the execution test was
shorter than the watchdog budget, so a watchdog armed before execFile
still passed.

* build: guard worker-thread entries against electron imports (#11161)

Electron's module is not registered on worker threads, so
require("electron") throws "Cannot find module 'electron'" inside a
main-process worker and kills it at startup (verified on Electron 43.1.0).
plain-node-entry-guard covered only forked plain-Node entries, so the five
worker entries relied on hand-written "must stay electron-free" comments.

The port-scan probe worker is one import away from
port-scan-command-client.ts, which deliberately contains require('electron').
A violation there fails closed at runtime while every unit test still passes,
because the client's require is try/caught on the main thread.

Covers stt-worker, warp-theme-parser-worker,
session-scanner-opencode-sqlite-worker-entry, main-thread-hang-watchdog-entry
and port-scan-command-worker-entry. The scan is transitive over the emitted
chunk graph, so a shared chunk that reaches electron is caught too.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* test(windows): retry teardown for main's duplicate-PATH launcher fixture

Main's new csc-compiled harness runs an exe from the temp tree, which is
exactly the image-handle/AV lock the merged-in removeFixtureTree retry exists
for; its bare rmSync would report a teardown lock as a launcher failure.

Co-authored-by: Orca <help@stably.ai>

* test(ports): pin the packaged-asar worker entry path

resolveWorkerEntryPath's packaged branch never runs in dev or e2e, so the path construction had no coverage. Split the electron read out of it and unit-test both layouts.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 02:03:40 -07:00
Jinjing 00867f06e2 fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown

SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.

* fix(ssh): handle owner displacement and graceful shutdown

Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.

* fix(ssh): fix flaky test sync and add error code safety check

Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.

* fix(ssh): fence reset transport opens during shutdown

* fix(ssh): keep recovery leases stable across reconnects

* fix(ssh): close transports owned by cancelled connect attempts

When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.

* fix(ssh): fence old owner proofs and close superseded transports

When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.

This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.

* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling

When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
2026-08-04 01:37:25 -07:00
NeilandOrca 96e31e7bf8 Remove a paired computer's deleted projects from every connected device (#12215)
* fix(repos): remove a paired computer's deleted projects from every connected device

A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.

Two independent defects:

1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
   renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
   fed only by mutations arriving over runtime RPC, and clients refetch a remote
   catalog only on a `reposChanged` event -- there is no polling on desktop -- so
   the deleted rows persisted indefinitely. The shared `notifyReposChanged`
   helper now also calls the new
   `OrcaRuntimeService.notifyReposChangedForRemoteClients()`
   (src/main/runtime/orca-runtime.ts:5175), mirroring the existing
   `notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
   project-group and folder-workspace IPC mutation, so renames, colors, reorders
   and adds propagate too.

2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
   answered `repo_not_found`. `removeProject` wrapped its whole body in one
   try/catch, so the rejection aborted the local purge before the `set()`
   (src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
   to do nothing. Only `repo_not_found` is now tolerated; any other failure still
   keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
   three single-project user-initiated entry points. Bulk and background callers
   keep today's silence plus their own aggregate reporting.

Closes #11994

Co-authored-by: Orca <help@stably.ai>

* fix(repos): revert inert RepositoryPane removeProject arg

The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.

Co-authored-by: Orca <help@stably.ai>

* fix(repos): scope duplicate-repo-id deletes to the owning execution host

Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.

Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.

Co-authored-by: Orca <help@stably.ai>

* test(mobile): align the poll-interval rationale with the new reposChanged emission

Co-authored-by: Orca <help@stably.ai>

* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host

The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:26:48 -07:00
OrcaWinandOrcaWin ac7f9a4fe1 fix(pty): require proven absence before retiring a stable pane owner (#12393)
* fix(pty): require proven absence before retiring a stable pane owner

"Session not found" only proves the provider we asked has no such PTY. A
degraded router answers unmapped session ids from the local fallback, which
never owned a daemon session, so a live agent's PTY produced the same error a
dead one does — and the pane was then given a synthetic exit, its durable
pane->PTY binding deleted, and a duplicate spawned. For a single-pane tab the
retirement also drops the tab from persisted state, which the renderer cannot
add back.

Gate the retirement on probePtyLiveness, which polls every possible owner and
answers false only when all of them agree the session is absent. `null` (nobody
could answer) is not absence. Providers without a probe are their own sole
owner, so their refusal stays authoritative and their behavior is unchanged.

Also move the retirement's durability barrier off writeToDiskSync: it fsyncs
the whole multi-MB state from the main thread, and a restore that retired N
dead panes paid that stall N times.

* fix(pty): make the unproven-absence veto transient and route attaches to the real owner

Review found the proven-absence gate correct in direction but terminal in
effect: "probe says alive" and "probe says unknown" both became a hard spawn
failure with no consumer, no retry, and a raw internal token in a toast.

- Graceful teardown (should-fix): a session being killed reports alive
  (getAppliedSize answers while isAlive, and isTerminating does not flip
  _state) while createOrAttach already throws SessionNotFoundError, so for up
  to KILL_TIMEOUT_MS the pane failed instead of retiring the doomed binding.
  The veto now re-proves on a bounded 6s budget: the moment any owner answers
  "absent" the pane retires and spawns fresh exactly as before.

- Unprovable owners (should-fix): DaemonPtyAdapter.probePtyLiveness never
  called ensureConnected, so a merely disconnected adapter answered null
  forever and one null poisons probePtyOwners; it also took no deadline, so a
  wedged daemon burned the client's 30s request timeout per attach. It now
  connects first (like listProcesses) and threads an absolute deadline through
  the fan-out. The thrown error is user-legible English instead of
  terminal_pane_owner_unverified.

- Durability barrier (should-fix): flushPendingOrThrowAsync is not a twin of
  flushOrThrow — it defaults to the drain-to-stable-generation loop the
  sibling best-effort caller deliberately avoids, adds active-view and GitHub
  sidecar writes, re-serializes state per iteration, and rejects when writes
  are frozen where the sync path no-ops. Reverted to flushOrThrow, matching
  the sibling retire paths in orca-runtime.

- Misrouting root cause (nit): DegradedDaemonFreshSpawnRouter.spawn sent an
  unmapped sessionId to the local fallback and DaemonPtyRouter.spawn sent it to
  the current daemon, both faking "Session not found" for a live session. Both
  now resolve an existing owner first (the same resolution every non-spawn path
  already uses); minted ids still route to the fallback/current daemon. The
  pane reattaches instead of only surviving.

Tests: retry-until-proven, teardown-window retire, misroute-then-reattach,
probe deadline threading, unconnected-adapter probe, and both routers' owner
resolution. Each fails with only the source reverted.

* fix(pty): drop the follow-up, keep the minimal proven-absence guard

Reverts aa27eb5274 and leaves only the original guard: retire a stable pane's
owner solely when a provider authoritatively answers absent
(probePtyLiveness === false). `true` and `null` still refuse to authorize
destruction, and providers without a probe keep their refusal authoritative, so
their behavior is unchanged.

The follow-up bought too much surface for a P0, and two of its pieces did not
hold up:

- The `hasPty` owner scan added to DaemonPtyRouter.spawn and the degraded
  fresh-spawn router is inert for the case it targeted. `hasPty` reads
  `activeSessionIds`, which only spawn/attach populate — `listProcesses`
  (daemon-pty-adapter.ts) never does. After a failed discovery the scan
  therefore cannot find the owner it was written to find.
- Its two routing tests could not fail: the mocks backed `hasPty` and
  `listProcesses` with one shared array, giving the fake adapter knowledge the
  real DaemonPtyAdapter provably lacks.

Also dropped: the bounded 6s retry loop and its constants, the sentinel symbol,
the exported user-facing message, the `opts.deadlineMs` widening of
probePtyLiveness and its threading through the probe/routers, and the
ensureConnected change in DaemonPtyAdapter.

Kept from the follow-up: the flushOrThrow revert. flushPendingOrThrowAsync is
not a like-for-like twin (drain-to-stable-generation loop, sidecar writes,
per-iteration serialize, rejects when writes are frozen), so
retirePersistedStablePaneOwner stays synchronous.

One gap the strict one-shot gate does open is closed here. `getSize` landed in
daemon protocol v18, and legacy adapters are created for every version in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS (1..30). A pre-v18 daemon error-replies
"Unknown request type", probePtyLiveness catches it as `null`, and one `null`
makes probePtyOwners unprovable forever — so a genuinely dead pane could never
retire. probePtyLiveness now version-gates getSize and asks such a daemon for
`listSessions` instead, the same inventory legacy discovery already routes by,
requested directly rather than through listProcesses (which swallows errors into
an empty list) so an unreachable socket still answers `null`. Unreachable never
means absent; only an owner that answered does.

* fix(daemon): detect getSize support instead of inferring it from the protocol number

The version gate was not a sound boundary. `getSize` landed in 22b00a7cd2 while
PROTOCOL_VERSION was already 18 and that commit did not bump it, so version 18
means two different things: daemons built before it lack the request, daemons
built after it have it. Both report 18.

A daemon from that window therefore passed the `>= 18` gate, was sent `getSize`,
error-replied "Unknown request type", and answered `null` — and one `null` makes
the owner fan-out permanently unprovable, so a genuinely dead pane could never
retire. That is the same wedge the gate was added to close, just narrowed to one
build window.

Ask the daemon what it supports rather than trusting its number: attempt
`getSize`, and fall back to `listSessions` only when the daemon itself replies
that the request type is unknown. The predicate is deliberately narrow so a
transient failure stays unproven instead of being mistaken for a missing
capability, and the rejection is remembered so later probes skip a round trip
that cannot work. This mirrors the capability-probe rule AGENTS.md already
requires for Git, for the same reason: version numbers overstate capabilities.

Unreachable still never means absent.

* test(pty): pin that proven absence still retires, and correct a wrong rationale

Review found the guard had no positive-direction coverage: every existing retire
test uses a provider with no probePtyLiveness, so the guard is skipped there.
Mutating `!== false` into `!== undefined` — which turns the guard into a
permanent veto, so no daemon-backed pane could ever recover from a genuinely
dead owner — left the whole suite green. Pinned with a case that fails under
exactly that mutation.

Also corrects the fallback's Why-comment. It claimed listProcesses "swallows
errors into an empty list"; it does not — DaemonPtyAdapter.listProcesses
rethrows and the router fails closed. The error-swallowing lives in
discoverLegacySessions / discoverDegradedDaemonSessions. Requesting listSessions
directly is still right, but because a liveness probe should not publish
inventory audit observations as a side effect — not for the stated reason.

* fix(daemon): bound the liveness probe so a wedged daemon cannot stall a pane mount

probePtyLiveness passed no timeout, so each request fell back to the client's
30s REQUEST_TIMEOUT_MS. A wedged daemon holds its socket open rather than
refusing, so it answers neither quickly nor at all — and probePtyOwners awaits
every adapter before it can conclude anything. One hung daemon therefore stalled
each restoring pane for up to 30s before the pane reported that it could not
reopen.

Nearly all local terminals are daemon-backed, so this is the common
configuration, not an edge case.

Bound both probe requests to 2s. Answering "unknown" quickly is strictly better
here than answering slowly: unknown never authorizes retirement, it only defers
it, so the only thing a shorter budget can cost is an earlier retry. The tests
now pin the bound at the call rather than leaving it implicit.

* test(daemon): pin the getSize probe timeout, and reattach an orphaned comment

Review proved a coverage gap by mutation: removing LIVENESS_PROBE_TIMEOUT_MS
from the getSize request left the whole suite green, so nothing stopped that leg
regressing to the client's 30s default. Only the listSessions leg was pinned.
The assertion now fails under exactly that mutation.

Also moves isUnknownRequestTypeError below isDaemonGoneError. It had been
inserted between isDaemonGoneError and the comment describing it, so that
comment read as documenting the wrong function.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:48:18 -07:00
OrcaWinandOrcaWin 141b1f43f6 fix(runtime): keep the listener on loopback for a "This computer only" pairing link (#12405)
* fix(runtime): keep the listener on loopback for a "This computer only" pairing link

The runtime pairing URL handler called ensureNetworkExposure() for every
offer, including one whose advertised address is loopback. Settings ->
"Share this Orca server" offers a "This computer only" radio that pairs
against 127.0.0.1 precisely so nothing is reachable off-host, yet choosing
it rebound the WebSocket listener from 127.0.0.1 to 0.0.0.0 — and the widen
never narrows back, so the runtime stayed exposed to the whole LAN for the
rest of the process after the user picked the option that exists to avoid
exactly that.

Gate the widen on the advertised address: only a non-loopback endpoint (LAN,
Tailscale, custom host) needs a listener reachable off this machine, so those
paths keep widening exactly as STA-2370 intended. A loopback link is already
served by the loopback listener, so it now mints without touching the bind.
Classification reuses the shared pairing-address classifier, which also covers
localhost, ::1 and 127.0.0.0/8 typed into the custom-address field.

Tests: a real OrcaRuntimeRpcServer driven through the IPC handler asserts the
bind host stays 127.0.0.1 after a local link and flips to 0.0.0.0 after a
LAN one, plus handler-level cases for 127.0.0.1 / localhost / ::1.

* fix(runtime): gate the pairing widen on the user's declared reach, not the address shape

Review of #12405 found two ways the loopback fix misbehaved.

1. The guarantee died at the next launch. resolveInitialWebSocketBindHost()
   binds 0.0.0.0 whenever any device has lastSeenAt > 0, and MobileSocketWiring
   stamps that for EVERY authenticated socket — including the local browser
   opening a "This computer only" link. So the runtime was still published on
   every interface, one restart later. Grants now carry the reach they were
   minted for (DeviceEntry.pairingReach, persisted); a this-computer grant no
   longer counts as proof that an off-host client may reconnect. Registries
   written before the field default to network reach, so an already-paired
   phone still finds a wide listener after upgrading. A pending grant that is
   re-advertised for the network widens (never narrows) so its link survives.

2. The widen was gated on the shape of the typed address, which the renderer
   never sent the intent for. A Custom `127.0.0.1:8443` — the documented SSH
   tunnel / reverse proxy field — skipped the widen and produced a dead link,
   while `localhost:8443`, `[::1]:6768` and `ws://127.0.0.1:6768` widened, so
   the same loopback intent was handled three different ways. The renderer now
   sends the declared reach ('this-computer' | 'network') and main gates on it;
   the address is only used as a mismatch guard (a this-computer reach carrying
   an off-host address still widens rather than minting an unreachable link),
   resolved through resolveAdvertisedPairingHostname so every accepted address
   form classifies identically.

Also corrected the ensureNetworkExposure invariant comment: the widen is no
longer confined to the first pairing action, so it can now tear down live
loopback sockets — they reconnect on the reused pinned port.

Tests: reach-form matrix + tunnel/undeclared/mismatch cases in mobile.test.ts,
real-server relaunch bind for both reaches, legacy registry compatibility, the
pending-grant reach upgrade, a live-client port-stability guard, hostname
resolver coverage, and the renderer reach plumbing. Reverting only the source
fails 18 of them.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:44 -07:00
Brennan Benson ed4d6979b1 fix(app): await durable checkpoints before restart actions (#12433)
* fix(app): await durable checkpoints before restart actions

* fix(app): clear restart latch after refused reload

* fix(persistence): invalidate hash after stale rename
2026-08-03 23:11:36 -07:00
NeilandOrca 0927b9c156 fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266)
* test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): load pipeline job traces in the Checks side panel (#7732)

Expanding a GitLab pipeline job in the Checks panel always showed
"No inline details are available for this check.": the mapper dropped the
numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer
called the GitHub check-runs API, which returns null for a GitLab job.

- carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to
  all three identity ladders (panel rows, editor tabs, fix-prompt keys) so
  same-stage jobs with no web_url stop colliding
- add a runtime-routed trace client so SSH/remote workspaces work, not just
  local IPC, and thread the MR's `projectRef` for fork pipelines
- bound the trace in main via the existing `sliceCheckLogTail` (now shared,
  not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB
  transport frame cap; strip ANSI/section markers up to the CR only, which
  keeps each section's visible header and command echo
- render the excerpt inline instead of "Log tail available in full details."
- feed GitLab traces to "Fix with AI", which previously sent bare check names
- skip the fetch for jobs that cannot have a trace (created/manual/skipped)
  so GitLab's 404 does not replace the benign empty state, and re-arm a
  failed load when the job's state changes since the panel has no retry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): treat a missing job log as an empty log, not an error (#7732)

Round-1 review follow-up.

- a job canceled before it started (or whose log was erased/expired) is
  `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404,
  and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been
  deleted.") landed verbatim on the auto-expanded check row; main now maps that
  404 to an empty trace so the row keeps its benign empty state
- keep a missing project a real error (GitLab masks unauthorized projects as
  404) and add `classifyJobLogError` so 403/unknown failures stop borrowing
  issue-edit wording on a job-log read
- broaden the empty-log copy in all five catalogs: it now covers erased and
  expired logs, not only jobs that never ran
- e2e: derive the repro screenshot dir from `process.cwd()` (or an env
  override) instead of a hardcoded POSIX path to a throwaway worktree
- bound the raw trace before the ANSI/section passes so a multi-megabyte log
  is not scanned in full on the main-process event loop
- drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead
  "Log tail available in full details." catalog entry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout

- Carry the MR's GitLab project ref on the check-details tab so reloading a
  fork/cross-project job tab fetches the trace from the pipeline's own project.
- Re-arm the sidebar retry when a details load resolves to null, not only when
  it throws; a detail-less row otherwise never retried after the job moved on.
- Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime
  RPC path uses — glab runs without a subprocess timeout in main.
- Document that the trace 404 -> empty-log mapping is deliberately broad.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:37 -07:00
Brennan Benson e43bd6c4ad fix(worktrees): preserve folder PTY owner scope (#12429) 2026-08-03 22:29:19 -07:00
NeilandOrca a6b14eb04c fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) (#12202)
* fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112)

#12101: a force-killed TUI never emits its DECRST reset, so its armed mouse
mode is latched into the on-disk checkpoint and re-derived into the
replacement process's emulator via the cold-restore history seed -- through
both rehydrateSequences and SerializeAddon's own mode trailer. The revived
bare shell then echoed SGR motion reports at the prompt. Seed a
RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape
tail), only when there is real recovered content so the empty-array
"nothing to recover" sentinel survives.

#12112: agent panes arm a main-side PtyStartupIngress that answered opencode's
startup OSC 10/11 queries synchronously inside node-pty's onData, while the
POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back
out as visible text. Echo suppression existed but was gated on windows-conpty.
Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and
recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY
keeps its synchronous write; windows-wsl is byte-identical to before.

Fixes #12101

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): read the slave's ECHO bit before answering a color query

The startup color reply was written into a PTY still in cooked mode, so the
line discipline echoed it back as visible junk (#12112). Whether that will
happen is readable state on the slave rather than something to infer from
returning bytes, so the reply now waits until the ECHO bit is observably
clear instead of guessing at echo shapes.

Two echo sources exist and only one is readable. A `quiet` verdict proves
the kernel will not echo, so it retires the caret projection; readline
echoes a master write in software with the tty already raw, so that
projection stays armed on every path. Scoping `quiet` narrowly is the whole
correctness argument here: reading it as "no suppression needed"
reintroduces the bug at a plain shell prompt.

Polling is bounded by a wall-clock budget rather than an attempt count,
because each probe is a subprocess and a multi-pane restore serializes them
on fork. Withholding measures flat at ~210ms from 1 to 100 panes.

Also resets a cold-restored pane's mouse reporting (#12101). The armed mode
is re-derived from the dead process's own persisted bytes through two
channels, so the daemon seeds a reset into recovered history and the
renderer stops trusting a persisted "live agent" signal after a cold
restore. The reset literals move to one shared profile module.

Fixes #12101
Fixes #12112

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin the cold-restore reset on the spawn-adopted reattach path

A spawn can be answered with an adopted session, which reaches the reattach
handler by a door that skips the restored-session path. Pin that the cold-restore
signal survives it, so #12101's junk cannot come back through it.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): note why the adopted-reattach snapshot leaves the cursor visible

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): harden startup reply delivery

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 21:00:57 -07:00