Commit Graph
9377 Commits
Author SHA1 Message Date
Neil cf5e087246 fix(github): bound PR-refresh alias fan-out and hosted-review cache growth (#16943)
* wip: memory-growth

* fix(github): preserve newer refresh candidates
2026-08-27 22:13:01 -07:00
Jinwoo Hong 8dd7d6060c fix(release): stabilize native builds across CI platforms (#16947) 2026-08-27 21:42:34 -07:00
Jinwoo Hong bf5660df51 feat(mobile): add causal network diagnostics (#16837) 2026-08-27 21:42:23 -07:00
Jinwoo Hong cb848647e5 fix(browser-preview): require explicit preview capabilities (STA-5758) (#16921)
* fix(browser-preview): require explicit preview capabilities (STA-5758)

Scope document reads to approved directories, confirm external links before opening them, revoke grants with tab lifecycle, and keep document-preview session state rollback-safe across mixed client/runtime versions.

* Harden document preview lifecycle and permissions

* Document preview DNS prefetch residual

* Make preview E2E guest focus explicit

* fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758)

A grant whose document directory is its own request base — a doc at the
workspace root, or outside any workspace — now reads nothing but the entry
file until the reader approves a directory, at both the lexical and the
canonical containment pass. The DNS-prefetch residual can only beacon what
the page can read, and a root-level document could previously read the
whole worktree silently.

The identity chip's host badge overflowed the chip's layout box under
squeeze (Linux CI): every row member can now shrink and truncate, verified
by a width sweep in isolated Chromium down to ~120px chips.

The Allow banner says what it grants: 'Allow folder', reading files in the
named directory, for the life of the preview.

The reliability-gate manifest command, testFiles entry, assertion refs and
dated evidence naming the deleted doc-preview-external-link-bridge.test.ts
are re-issued at doc-preview-external-link-confirmation.test.ts with a
fresh 189/189 run; the focus-gate assertion text follows the shipped gate.

* fix(browser-preview): hide the chip identity row below 24rem instead of clipping it, ellipsize the host badge, catalog the new i18n keys (STA-5758)

CI's preview pane leaves the chip ~40px: no truncation shows anything
there, so the Workspace-file label and host badge now hide whole below a
24rem container threshold sized so that visible implies contained. The
badge text gains an inner text box — text directly inside the flex pill
clipped both ends with no ellipsis. The e2e geometry oracle asserts
containment when the row shows and the threshold when it does not.

verify:localization-catalog: the hardening's new preview keys (and the
renamed allowDirectory) join en.json via sync:localization-catalog.

* feat(browser-preview): batch blocked folders into one access decision (STA-5758)

Sequential per-folder banners trained the allow reflex without adding
judgment — a reader cannot weigh assets/ against data/. The banner now
accumulates every folder a load surfaces, names them (three, then a
count, full list in the title), and grants exactly that set with one
Allow-N-folders click and one reload. Dismiss fences the whole named
set. The map lives behind a ref with a version tick so a dismissal
fences an offer landing in the same event batch.
2026-08-28 00:42:07 -04:00
Jinwoo Hong b19a397d3e feat(browser-preview): reland remote HTML document previews (STA-5758) (#16920)
Reapply the reverted remote HTML document preview implementation so remote workspace files render locally over the orca-preview scheme.
2026-08-28 00:27:01 -04:00
Neil c72afda498 wip: react185-cascade (#16940) 2026-08-27 20:56:00 -07:00
Neil c54bf92181 fix(window): stop will-navigate from blocking the lazy-chunk recovery reload (#16944)
* wip: lazy-chunk-reload

* fix(window): reject non-document navigation schemes
2026-08-27 20:29:42 -07:00
Jinjing 6f3c5cc039 fix(i18n): use 工作树 consistently for parent worktree strings in zh locale (#16954)
* feat: select parent worktree for nesting when creating workspace

Enable users to pick a parent workspace in the composer's Advanced drawer,
organizing newly created worktrees hierarchically in the sidebar. The backend
validates lineage relationships and gracefully retries without the parent if
it becomes unavailable during creation.

* feat: select parent worktree for nesting when creating workspace

- Record app-picked parents as manual actions, not CLI-flag equivalents,
  ensuring the same user action carries consistent cleanup semantics across hosts
- Gracefully retry without parent if the selection goes stale, warn the
  user instead of failing
- Refactor create into modules: parent resolution, payload building,
  state merge

* Allow selecting parent worktree when creating workspace

- New parent worktree picker filtered by execution host and project
- Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership

* Allow selecting parent worktree when creating workspace

- Rename "Parent workspace" label to "Parent worktree"
- Filter candidates by execution host and project to prevent nesting across hosts
- Wire parentWorktreeId through composer state and creation request pipeline
- Update translations and add new copy for nesting-related messages

* fix(i18n): use 工作树 consistently for parent worktree strings in zh locale

Addresses PR #15420 review feedback: parent worktree strings mixed
工作区 (workspace) and 工作树 (worktree) terminology.
2026-08-27 20:18:04 -07:00
Neil 6f8c5888b3 Run Node 26 compatibility daily instead of per PR (#16946)
* Run Node 26 compatibility daily

* Update relocated unit workflow contracts
2026-08-27 20:17:25 -07:00
Neil 3d796b82cb fix(claude-usage): bill 1-hour cache writes at 2x base input (#16878) 2026-08-27 20:13:14 -07:00
Jinjing 52ade074a9 Display host on automation details and dialog (#16823)
* Show automation host in details and support moving between hosts

- Rename AutomationCreateDestinationField to AutomationDestinationField to
  reflect dual use in create and edit modes
- Add host display to automation detail view, showing storage authority
- Allow editing automations to move them to different hosts within same
  authority; project list filters to available projects on chosen host
- Update copy from create-only terminology to mode-agnostic wording

* Display automation host and support cross-authority moves

Users can now move automations to different storage authorities. The
destination picker shows all available hosts, and selecting a new one
displays a warning about the move. The save creates the automation on
the destination and deletes it from the source; if deletion fails,
both copies remain and the user is notified.

* Remove cross-authority move support for automations

An automation's authority (the Orca instance that stores and schedules it)
cannot change; edits now only offer hosts within the same authority and
move logic is removed entirely. This simplifies the destination picker and
removes move-specific UI messaging.

* Fix undefined selectedRowKey in automation host recovery

Replace references to the undefined selectedRowKey variable with
selectedRow?.key to properly access the row's key when recovering
automation runs across hosts.

* Support moving automations across execution authorities

Allows users to move automations between different authorities (desktop ↔ runtime environments) during editing. A save to a different authority creates the automation on the destination and deletes the original with its run history. Includes clear messaging about the move operation, proper handling of workspace id resets, and graceful error handling when deletion fails. Supports destination-aware project and worktree fetching.

* Reuse creationKey across move retries when schedule changes

When retrying a failed automation move, dtstart is minted fresh each
attempt, changing the payload. Previously, operationKey included the
full payload, so retries would mint new creationKeys and risk duplicate
automations on the destination if the initial create failed in transport.
Now key only by the move (source + destination) to ensure stable
creationKey across retries.

Also fix workspace auto-selection to use authority-scoped worktrees
instead of the merged cache, preventing unwanted restoration of
source-host workspaces after switching authorities.

* Rename `note` to `moveWarning` for automation host moves

Clarifies that the field specifically warns when an automation would move to another host, replacing the plain storage line.
2026-08-27 20:10:17 -07:00
Neil 06272109da wip: remote-sidebar-offline (#16942) 2026-08-27 20:07:46 -07:00
Neil 6b4537fa01 wip: ssh-hidden-output (#16941) 2026-08-27 20:04:57 -07:00
Neil 25fa6a9920 fix(ssh): a relay that reports a PTY absent must authorise a fresh spawn (#16763)
* fix(ssh): a relay that reports a PTY absent must authorise a fresh spawn

reattachSshPtySession flattened the relay's `PTY "<id>" not found` into a bare
`SSH_SESSION_EXPIRED`, discarding the evidence. isPtyAlreadyGoneError classifies
on that message via /PTY ".+" not found/, so it could never match — and
attachStablePaneOwner's existing already-gone fallback (retire the pane binding,
onPtyExit, spawn fresh) is gated on exactly that predicate. The recovery
machinery was already correct; only the classification was broken, which is why
`pty:spawn` rejected outright after a relay restart renumbered its PTYs from
pty-1.

SshPtyAbsentFromRelayError carries the verdict the message cannot: a reachable
relay answered for this exact id and reported it absent. Both thrown messages
stay byte-identical to the previous ones, so all six message-based consumers are
untouched; only callers that can act on the stronger verdict test the class.

Deliberately not raised for a transport failure, timeout, disposed multiplexer,
identity mismatch, or restoreRequired — none of those observe the process, and
treating them as absence would orphan live remote work.

* test(ssh): cover the absence fallback against real persistence

The first cut passed no store/worktreeId, so attachStablePaneOwner's guard
short-circuited and retireTerminalSurfaceFromPersistence — which deletes the
parent tab and its layout when the retired leaf is the last one — never ran.
retirePersistedStablePaneOwner and attachStablePaneOwner had no coverage at all.
2026-08-27 19:49:00 -07:00
Neil 2b391652b1 fix(terminal): a close the host never heard must survive the reconnect (#16752)
An enterprise user: "Every day I open orca and it opens more tabs daily at a
linear scale." Three reports over a week, told on 08-19 that a PR had fixed it,
reported twice more after. STA-4658 (P0), GH #12447, #15136, #10342, #9585. One
install held 39 zombie tab records. The revived tab's sleeping-agent record still
holds the pre-close session id, so it boots `claude --resume <old id>` -- two
agents on one transcript.

## The chain, measured

Reproduced deterministically in `ssh-lost-kill-tab-resurrection.spec.ts`: close
an SSH tab, kill the relay daemon in the container so `pty.kill` rejects with a
transport-class error, reconnect.

    drop 2 resurrected the closed tab <id>:
      baseline=1  drop1=1  drop2=2 (closed tab returned)  drop3=1

The trigger is narrow and had to be measured rather than assumed: killed relay
daemon reproduces **6 of 6 runs**; an orderly `ssh.disconnect` **passes**. Only
an ungraceful loss -- network partition, host reboot, relay crash, a laptop
sleeping mid-session -- strands the close with the RPC rejecting on a
transport-class error. Both variants live in the spec behind one
`runResurrectionCycles` parameterized solely by the disruption, so the difference
is attributable to that single variable.

What actually carries the tab back, from the pull path
(`workspace.get` -> `getRemoteSnapshot`, `remote-workspace-relay-sync.ts:29`):

    pullSnapshot rev=3 tabs={repo:["16c4a3e1","06aba6b6"]}
    pullSnapshot rev=4 tabs={repo:["16c4a3e1","ff72768e"]}   <- ff72768e IS the resurrected tab
    pullSnapshot rev=5 tabs={repo:["16c4a3e1","ff72768e","da21b76c"]}

The client uploaded the session containing the tab; the user closed it; the kill
RPC rejected so the close never reached the host; the host's snapshot still lists
it; the client pulls it back and the merge restores it -- **correctly, by its own
rule that the host is authoritative for what it knows.** A pane then mounts,
respawns, and takes the recycled pty id.

Client-side correlation from the same run, two controls and one positive in one
run differing in exactly one variable:

| Tab | Close events observed | Resurrected? |
|---|---|---|
| `6305cc07` | `user` + `pty-exit` | No |
| `ed56f66c` | `user` + `pty-exit` | No |
| `2036e760` | `user` only | **YES** |

## The fix

`src/shared/closed-terminal-tab-tombstones.ts` (99 lines). A client-recorded
close is first-party intent and must survive until the host acknowledges it. Per
`docs/reference/ssh-execution-boundary.md` the remote verdict is `unverifiable`
-- which may not authorise declaring the process dead, but equally must not
authorise resurrecting the tab. This is SSH-v3 principle P2, "durable tombstones
with a monotonic per-scope revision", reusing the existing
`RemoteWorkspaceSnapshot.revision` rather than adding a twelfth per-tab identity
field (the codebase carries eleven, 784 refs, that SSH-v3 Phase 3 deletes).

- **Recorded** only on `closeReason === 'user'` (`terminal-tab-close.ts:69`).
- **Suppresses** a host-sourced tab only when
  `tabId in tombstones && !currentTabsById.has(tabId)` -- a live local tab always
  wins, because deleting a live pane is the one outcome the merge exists to
  avoid.
- **Retires** on positive acknowledgement:
  `!hostKnownTabIds.has(tabId) && hostRevision > observed`. Strictly newer, so a
  pull already in flight at close time cannot ack a close it predates.
- Three never-retire guards: no revision retires nothing; a worktree the snapshot
  has no row for retires nothing; the first omitting snapshot only stamps the
  watermark.
- TTL (30d) + cap (500) are **backstops** for a target the user never returns to,
  not the mechanism.
- **Client-local only** -- never crosses the wire, so there is no mixed-version
  exposure.
- Suppression is scoped to `replaceWorktreeIds`, which is what makes the
  live-tab check meaningful. A final whole-map sweep over the assembled
  `tabsByWorktree` would break that (a live tab is absent from `currentTabsById`
  outside the scope and would look suppressible); it is deliberately not there,
  and the comment at the top of the function says so.

## Evidence

The load-bearing evidence is an A/B control on one tree, not the oracle's
assertion. Flipping `isSuppressedByClose` to `false` -- one character --
reproduces the resurrection on demand:

    --repeat-each=2:
      1) drop 2 resurrected the closed tab ab0e305d-…: baseline=1 drop1=1 drop2=2
      2) drop 2 resurrected the closed tab 51533e34-…: baseline=1 drop1=1 drop2=2
      2 failed

With suppression on: **0 occurrences of "resurrected the closed tab" across five
runs plus one independent run by a second agent.** Provenance verified
positively, not by mtime: `closedTerminalTabTombstonesByTabId` appears 13x across
3 renderer chunks including `store-Do3KBvRE.js`; for every red control run
`mayCreate` appeared 0 times in `out/main/index.js`.

At the unit layer, disabling the same predicate: 3 failed | 39 passed. Restored:
42 passed; 287 across the workspace-session, terminal-store, remote-workspace,
shared-tombstone and profile suites; 24 in the four tombstone suites.

## The oracle spec: GREEN in the full lane

`ssh-lost-kill-tab-resurrection.spec.ts` passes both tests at this commit. Full
Docker-SSH lane, clean tree:

    BUILD_SHA=49bb96e0b4c   DIRTY=0
    PROVENANCE  tombstone=13  hasLocalTabsRow=2  hostAuthority=4  mayCreate=3
    14 specs / 20 tests -> 17 passed, 2 failed, 1 skipped (10.7m)

    [12/20] :178 does not resurrect tabs whose kill was lost to a killed relay
            daemon                                                      PASSED
    [13/20] :190 does not resurrect tabs closed while the host is
            disconnected                                                PASSED

    grep -c "resurrected the closed tab"  (whole lane)  -> 0

It passes WITHOUT PR 7 in the build (`mayCreate` present,
`SshPtyAbsentFromRelayError` absent), so the bug-2 fix below is not required for
it.

Test 1 fails intermittently in ISOLATED single-spec runs, where a third defect
blocks its cycle-2 setup. The resurrection assertion itself has never failed with
this fix in place -- the intermittent failure is always a setup failure, never a
resurrected tab. A reviewer running the spec alone may see it red; that is not
this fix regressing.

Three defects sit under STA-3374 and should not be conflated:

- Bug 1 -- the closed tab resurrects. Fixed here.
- Bug 2 -- `ssh-pty-session-reattach.ts:227-231` rewrites the relay's
  `PTY "pty-1" not found` into a bare `SSH_SESSION_EXPIRED`, so
  `isPtyAlreadyGoneError`'s `/PTY ".+" not found/` cannot match and
  `attachStablePaneOwner:242`'s already-correct fallback never runs. Owned by
  PR 7 (`nwparker/ssh-07-absent-from-relay`). Not required for the oracle above.
- Bug 3 -- after the daemon is killed and the client launches a replacement, the
  client's OWN SSH transport drops and does not reconnect within 60s: no
  "delay step 2/9", no handshake failure, nothing. `ssh-connection.ts:1533` only
  logs on an SSH-level close. Unfixed, its own ticket. This is what makes test 1
  intermittent in isolation.

Discriminator for bug 3, measured in the isolated runs (the lane above ran
without `ORCA_E2E_FORWARD_APP_LOGS=1`, so it was not re-confirmed there):
`[ssh-relay] Socket probe result:` reads "DEAD" on every cycle of test 1 (daemon
killed, a NEW relay must be launched) and "ALIVE" on every cycle of test 2
(daemon survived). Whenever a new daemon must be launched, the SSH transport
drops afterwards and does not recover.

An earlier reading blamed `kill.ts:82-84` for skipping `finishPtyShutdown` on a
non-already-gone error. That was eliminated by direct test: the implied fix,
`markSshRemotePtyLease(…, 'expired')` in that branch, was implemented, changed
nothing, and was reverted rather than shipped unproven. Recorded so the path is
not re-walked. The `SSH_SESSION_EXPIRED` rejection is real but fires during cycle
1 for the baseline pane, after which cycle 1 completes; the 60s silence begins
only after `Relay channel lost ..., triggering reconnect`.

The spec is claimed by the Docker-SSH lane, and that lane does not gate merges
today.

## Persistence: the tombstone must survive a relaunch

`closedTerminalTabTombstonesByTabId` is declared on `WorkspaceSessionState` but was missing from
`workspaceSessionStateSchema` (`src/shared/workspace-session-schema.ts`), which is the load boundary
for BOTH partitions -- `normalize-loaded-state-collections.ts` for `local` and
`workspace-session-partitions.ts` for `ssh:<target>`. Zod strips unknown keys and the write side does
not validate, so the map reached disk and was discarded on the next launch. Measured with the repo's
own parser:

    input : closedTerminalTabTombstonesByTabId: { 'tab-1': {...} }
    ok    = true
    tombstones after parse = undefined

That made the fix ineffective in the exact reported scenario: close an SSH tab with the transport
down, QUIT, relaunch, reconnect -- the merge runs with an empty map, the host still lists the tab,
and it resurrects. "Every day I open orca and it opens more tabs" is a claim about restarts.

Neither the green oracle nor the A/B control could see it: both run entirely inside one app process.
It also made the 30-day TTL and the 500 cap unreachable.

Fixed by adding the field with a `salvagingRecord` matching its sibling
`terminalSurfaceTombstonesByPaneKey`, so one malformed entry drops that entry rather than the map.

`workspace-session-schema.ts` was one line under its 300-line max-lines limit, so adding the field
required room rather than a suppression (the project forbids max-lines disables and per-file bumps).
Two value schemas were extracted to modules named after what they contain:
`terminal-tab-id-schema.ts` and `terminal-surface-tombstone-schema.ts`. The closed-tab tombstone's
own schema is colocated with its type in `closed-terminal-tab-tombstones.ts`, which is where it
belongs -- omitting it from the session schema is exactly the drift that caused this bug.

`workspace-session-schema-field-coverage.test.ts` is the ratchet. Two sibling tables already pin
themselves with `satisfies Record<keyof WorkspaceSessionState, ...>`; this schema had no such guard
and is the one that fell behind. The new file adds both halves -- a `satisfies` list that makes a
forgotten field a compile error, and a runtime assertion that names it -- plus a
`parseWorkspaceSession` round-trip. Without the schema entry: 3 failed. With it: 3 passed.

## A host tab the user never closed could be deleted

`tabId in closedTerminalTabTombstonesByTabId` answers true for every `Object.prototype` key even on
an EMPTY map, because the map is a plain object from `Object.fromEntries`. A host tab whose id is
`toString` was filtered from the reconciled list, blocked from the host-unknown branch, and stripped
of its layout and session id. Tab ids are validated only as non-empty and colon-free, and `createTab`
honours caller-supplied id hints, so the id is reachable rather than theoretical. This was the only
path in either direction that could delete a tab the user never closed.

Now `Object.hasOwn`, as the same file already uses elsewhere.

Suppression is also scoped structurally: `isSuppressedByClose` compares the tombstone's stored
`worktreeId`, which it already carried, so it cannot reach another workspace's tab. The two sweeps
that have no worktree in scope (`terminalLayoutsByTabId`, `remoteSessionIdsByTabId`) now consult the
set of ids this merge actually suppressed rather than re-deriving a verdict without that scope.

The scope comment at the top of the function was also wrong and is corrected. It claimed every use of
suppression sits inside `replaceWorktreeIds`; it does not -- the tabs pass walks all of
`orderedWorktreeIds` and the two sweeps cover the whole remote maps. What actually makes it safe is
that `closeTab` strips the id from every worktree row before recording the tombstone, plus the
worktree match above, plus `closeReason === 'user'` being the only writer. Real guarantee, different
from the documented one.

## Divergences from open PR #16571

#16571 implements the same concept. Three deliberate changes:

1. It never retires on acknowledgement -- TTL+cap only, so it never converges.
   Ack retirement added.
2. It crosses the wire and lets a HOST-sourced tombstone delete a LOCAL tab in a
   final whole-map sweep. After #14361 that is the wrong risk; dropped. This also
   removes the mixed-version regression its own body flags.
3. Its hydration unions rather than replaces the map -- a union resurrects every
   tombstone the merge just retired, so it never converges.

Its `activeTabId` nulling is also dropped as redundant:
`workspace-terminal-hydration.ts:99-105,126-138` already revalidates both
pointers against the tab rows it just built, and nulling twice would add a second
rule that has to stay in step with the first.

## Can a tab the user did NOT close disappear?

No, but the guarantee needs stating precisely. The only writer is
`recordClosedTerminalTabTombstone` (`terminal-tab-close.ts:69`), reachable only
on `closeReason === 'user'`; suppression additionally requires the tab not be live
locally. Reopen (`recently-closed-tabs.ts:122-166`) calls `createTab` and restores
cwd/shell/title/color/position, never the old id.

**Caveat, stated because the slogan is not literally true:** `createTab` honours a
caller-supplied id hint (`terminal-tab-creation.ts:53-65`, used by `useIpcEvents`
for host-admitted tabs), so "tab ids are uuids that never recur" does not hold in
this codebase. The guarantee rests on the `closeReason === 'user'` writer plus the
live-local-tab check, not on id uniqueness.

## Risk

Renderer-side, client-local, no wire change. The blast radius is
`mergeDirectSshRemoteWorkspaceSession` and the persisted session field. Worst case
if the ack logic were wrong in the retiring direction: a tombstone outlives its
usefulness and suppresses a host tab whose id the host re-issues -- bounded by the
live-local-tab check, the 30d TTL and the 500 cap. Worst case in the other
direction is today's behaviour. `profile-project-session-field-disposition.ts`
records the new field as `notRepoScoped` / `notTransferred` residue, bounded by
the same TTL and cap.

## Verify

    pnpm test src/shared/closed-terminal-tab-tombstones.test.ts \
      src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts \
      src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts \
      src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts

To reproduce the bug this fixes, set `isSuppressedByClose` to `() => false` in
`remote-workspace-session-merge.ts` and run
`pnpm test:e2e:ssh-docker -- tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts --repeat-each=2`.
2026-08-27 19:47:15 -07:00
Neil a7b1da9e14 fix(ssh): a reattach may bind a pane but must never create one (#16751)
`reattachKnownPtys` treats every non-terminated lease as live and calls
`persistPtyBinding`, which had no way to say "bind only". Two of its branches
then rebuild UI the user is not asking for:

- `pty-binding-persistence.ts:133-143` -- `if (args.incarnationId)`
  unconditionally deletes the pane's close tombstone.
- `:145-160` -- on a tab-not-found it mints one via
  `createMinimalPersistedTerminalTab`. The in-code comment names its only
  intended caller: "pty:spawn can beat the debounced writer." Spawn. Reattach
  took the same branch.

This is the mechanism canceled ticket STA-4268 described: "Leases have no pane
incarnation and upsert only by target/PTY. Every nonterminal lease is reattached;
frozen coordinates are passed to persistPtyBinding, which creates and flushes
missing tabs and layout leaves." It was fixed in #13326, reverted by #14361,
re-applied by #14384, and reverted again by #14395 (opened and merged nine
seconds apart, empty commit body). `grep -rn "mayCreate" src/` returns nothing on
main -- the mechanism is genuinely out of the tree.

Four parts:

1. `mayCreate` (default true). When false, one pre-mutation check mirrors all
   four creating branches and returns `false` without mutating, so a refusal
   leaves nothing half-written.

2. The authority gate -- the part both prior attempts lacked, and probably why
   both were reverted. `mayCreate: false` alone refuses in two situations: "the
   user closed it" AND "the renderer has not published its layout yet". The
   second is routine on disconnect->reconnect and is almost certainly the #14361
   tab-loss mechanism. The fence was not wrong; it was UNCONDITIONED. It is now
   passed only when `hasHostAuthoritativeTerminalMembership()` says the persisted
   membership speaks for this worktree, reusing the function already guarding the
   same question at `orca-runtime.ts:8608`. Losing a tab is worse than keeping a
   duplicate, so an unauthoritative session still gets the creating write.

   Authority is read from `local` because that is the partition the write lands
   in -- it is local's absence being interpreted. But a pane the `ssh:<target>`
   partition still holds is not gone, so it keeps its creating write; refusing
   there would strand a live pane behind a binding reattach can no longer reach.
   (SSH spawns bind into `ssh:<target>` while this reattach binds into `local` --
   GH #12721/#12723, STA-3980. This does not fix that split; it refuses to judge
   from one side of it.)

3. `findTerminalTabIdForLeaf` -- bind resolves the tab from the live layout
   instead of the lease's frozen `tabId`. Only the leaf half of a pane key is
   remint-stable: `detachTerminalPaneToTab` moves a live pane into a new tab, so
   a stored tabId names the tab the pane left. Identity vs location.

4. Pane-keyed supersession -- retires siblings on `(targetId, worktreeId,
   leafId)` to `expired`, guarded by the durable binding, which reads `local`
   then `ssh:<target>` so it is correct whichever partition the binding landed
   in. `upsertSshRemotePtyLease` matched `(targetId, ptyId)` alone
   (`ssh-pty-lease-operations.ts:34-36`), so a new relay pty id on reattach minted
   a SECOND lease instead of updating the first, leaving the predecessor
   non-terminated with nothing to retire it.

On refusal the lease goes `expired`, never `terminated` -- `expired` records that
this shell has no surface to reach it through; `terminated` would assert an exit
nothing here observed, which `docs/reference/ssh-execution-boundary.md` forbids.
The remote process is left running.

Deliberately NOT done:
- A collision guard for `upsertSshRemotePtyLease`. Built, tested, and REMOVED --
  its own test passed with the guard disabled, i.e. vacuous. Telling "same lease"
  from "recycled id on a different shell" needs a relay-start identity, which
  would be a twelfth per-tab identity concept; the codebase already carries
  eleven (784 refs) that SSH-v3 Phase 3 deletes. Left as an in-code NOTE. This
  handles lease DIVERGENCE, not COLLISION.
- A port of #13324. Its own authors deleted its load fold and reverted its
  local-only reader in #13326 ("a headless-owned pane still gets a vote before
  its lease is retired"); porting it ships a state-destroying migration they
  removed.
- `bindPaneShell` from #13325. Its purpose is making `isSupersededPtyId` live,
  and that fence does not exist in main. It would have been a refactor plus a
  silently-ignored `mayCreate` -- TS drops excess props through spreads
  (verified), which is why `mayCreate` is passed as a conditional spread here.

Honesty about scope: this does NOT close the daily-tab-growth report. The
deterministic e2e repro (`ssh-lost-kill-tab-resurrection.spec.ts`, later in this
stack) is byte-identical before and after, and instrumentation shows why -- in
that scenario `restoreReattachedPtyRuntime` is never called at all
(`CREATING TAB` 9 hits, `reattach gate` 0, `BYPASS` 0). The fence is on a path
that bug does not take. It is a real, separately-provable defect; it is not the
headline fix, and must not be claimed as one.

Evidence, A/B on this tree. Disabling the authority gate (`mayCreate = true`) and
the supersession call by hand: **8 of 12 fail**, including "does not resurrect a
tab whose closing pty.kill failed with a transport error" and "holds the live
lease count flat across ten reconnects of one pane"
(`[ 'pty-0', 'pty-1', 'pty-2', …(7) ]` vs `[ 'pty-9' ]`). The 4 that pass both
ways are the over-refusal tripwires, which is the point of having them. Restored:
12 passed; 2,439 passed across `src/main/ssh`, `src/main/persistence` and
`src/main/runtime/workspace-session`.
2026-08-27 19:45:49 -07:00
Neil e06a8667a9 fix(terminal): do not seed or resume while the execution host has not answered (#16750)
Two client behaviours read local tab rows as the verdict on what the execution
host is running. Before the host answers, "I hold no pane for this" is
`unverifiable`, not `exited` -- the collapse
`docs/reference/ssh-execution-boundary.md` forbids.

Symptom 1, seeding. `worktree-initial-terminal-seeding.ts:47,128` seeds a
terminal when `renderableTabCount === 0`. Its only bail-out (`:72-77`) covered
the paired-web-runtime flavor -- "while that session is live the host owns
terminal creation" -- with no equivalent for direct SSH. So a client that has
never held the workspace runs the predicate during the hydration gap and creates
a tab from nothing. The snapshot then arrives, the merge rightly keeps the tab it
was never told about, and the union uploads as the new host truth. Measured on a
fresh client against a host owning 3 tabs: **1 tab created from nothing, 0 of the
host's 3 adopted.** (A restart never reaches the predicate -- local state
restores the row first -- which is why restart-only repros came back flat.)

That guard was also the wrong question. It asked "am I a client of a live paired
session?", which a host desktop window answers "no" and a paired client answers
"yes", so both seeded -- #15556.

Symptom 2, sleeping-agent resume, and the data-corrupting half.
`Terminal.tsx:1554` calls `resumeSleepingAgentSessionsForWorktree` twenty lines
after the seeding call at `:1529-1534` -- same startup path, same pre-hydration
window, and not SSH-gated at all. Seeding produces a spare empty tab; the sweep
launches `claude --resume <id>` for a session still running on the remote and
still owned by a live pane. Two agent processes writing one transcript; STA-3498
observed five. STA-3500 files exactly this race. Failure is asymmetric: declining
to resume is user-recoverable, a duplicate resume corrupts a transcript
irreversibly.

`workspace-terminal-host-authority.ts` answers the one ownership question both
paths ask, in the three-verdict vocabulary the renderer already uses for host
terminal inventory (`HostLiveTerminalProbeVerdict`, aliased rather than restated
so the two cannot drift): `live` (a remote host owns creation here),
`unverifiable` (there is a remote host and it has not answered), `none` (local,
or the host answered and holds nothing). Seeding requires `none`; the sweep
declines on `unverifiable` without consuming its one-shot, so the agents are not
stranded for the session once the verdict lands.

Shape notes:
- An ownership question, not a client-liveness one -- that is what fixes #15556.
- Folder workspaces resolve to `none`: the snapshot replaces exactly
  `DirectSshTargetScope.gitWorktreeIds`, so a folder's rows are never replaced by
  the host and waiting for an answer that will never name them would leave it
  terminal-less for good.
- A `conflict` sync phase is `unverifiable`, matching the pair
  `use-app-session-persistence.ts` already gates uploads on.
- Explicit launch work (setup/issue commands) stays ungated -- that is a request
  to create a terminal now.
- `Terminal.tsx` subscribes through a retained selector rather than reading in
  the effect: the verdict flipping to `none` is what must re-run the passes, and
  resolution walks the owner catalogs, so recomputing per store write would be
  the STA-3363 render-path multiplier again.

The `unverifiable` verdict is BOUNDED, and must be. `remoteWorkspaceHydratedTargetIds` is add-only
in practice -- `markRemoteWorkspaceHydrated` has two production call sites, both on success paths,
and `clearRemoteWorkspaceHydrated` has NONE. Four paths return without marking: local-hydration
timeout (`remote-workspace-target-sync.ts:136-145`), a null `remoteWorkspace.get` (`:160-169`), a
falsy apply token (`:172-185`), and never connecting at all. Without a floor, any of them would
leave every git worktree on that target `unverifiable` for the rest of the app session: no initial
terminal, no sleeping-agent resume, escapable only by creating a tab by hand. That is strictly worse
than the behaviour it replaces -- on main the user got a terminal. So a sync that terminates in
`offline` or `error` without ever hydrating resolves `none`: declining to seed is meant to be a
wait, not a permanent refusal. `pulling` still declines, and a target that HAS hydrated stays `none`
even if a later sync errors.

Scope, stated because the doc comment previously overstated it: this gate is
first-hydration-per-target, not per-connection-generation. Since nothing clears the flag, a
disconnected target that hydrated once reads `none`. It does not cover mid-session reconnect or
sleep/resume.

The memo's input list is checked for COMPLETENESS, not just membership. `satisfies readonly
(keyof State)[]` only proves each listed key exists; a field added to the state and forgotten from
the list would type-check while making the memo return a stale verdict -- silent, and it looks like
"the gate did not fire". A conditional type now names the missing key at compile time. Deliberately
not `const x: Missing[] = []`, which passes regardless because an empty array literal is assignable
to every array type.

Known limitation, stated rather than hidden: the SEEDING half of this change has no measurable
end-to-end effect today, and the branch's own e2e spec says so.
`applyDirectSshRemoteWorkspaceSnapshot` calls `markRemoteWorkspaceHydrated` unconditionally AFTER
the hydrate calls -- including when they wrote nothing. So in the same tick adoption yields zero,
the verdict flips `unverifiable` -> `none`, `Terminal.tsx` re-runs the effect, and it seeds. The
gate cannot outlive the failure it guards against, because the same function that fails to adopt is
the one that lifts it.

`ssh-cold-hydration-gap-tab-seeding.spec.ts:218` is named for what it asserts -- one tab, adopted
none -- rather than for the behaviour we want. The fixme at `:293` pins the intended behaviour.

Making the seeding half effective needs hydration resolved PER WORKTREE (or a refusal to say `none`
when the completed apply's `replaceWorkspaceKeys` did not name this worktree) rather than a
per-target "some apply finished" flag. That is deliberately not in this commit.

The RESUME half is the valuable half and is unit-proven: it declines while the host is unanswered
and wakes the same session once the verdict lands, without consuming its one-shot. Preventing one
duplicate `claude --resume` on a live transcript is worth more than preventing one spare tab --
declining to resume is user-recoverable, a duplicate resume corrupts a transcript irreversibly.

Before: 7 failed | 3 passed. After: 10 passed; 103 across the seeding, resume,
authority and remote-workspace suites.
2026-08-27 19:44:30 -07:00
Neil 8b99c8365d fix(remote-workspace): host silence must not delete a local row it was never told about (#16748)
Two fields in `mergeDirectSshRemoteWorkspaceSession` treated an absent remote
entry as an authoritative delete. Both are records whose *absence* is meaningful,
so deleting them on silence loses information the host never had.

1. The closed-last-terminal tombstone.
   `src/renderer/src/components/terminal/initial-terminal.ts:5` states the
   contract verbatim: "a missing row means never initialized; an explicit empty
   row records that the user closed the last terminal." `mergedWorktreeIds` was
   `keys(remote.tabsByWorktree)` union the replaced worktrees whose local tab
   list is NON-EMPTY (`:41-45`), so a worktree holding an explicit `[]` and
   absent from the host snapshot was excluded, `omitTargetWorktrees` stripped the
   key, and nothing re-added it. Measured before the fix:
   `Object.hasOwn(merged.tabsByWorktree, WORKTREE)` => false.

   Downstream, `worktree-initial-terminal-seeding.ts:125` computes
   `shouldHonourClosedTerminalTombstone = Object.hasOwn(store.tabsByWorktree, id)
   && ...` => false, so `shouldAutoCreateInitialTerminal(0, false)` => true and a
   terminal is created. The user closes their last terminal on an SSH workspace
   and it comes back on reconnect.

   The projection is innocent: `remote-workspace-session-projection.ts:47-59` and
   `:161-168` both round-trip an empty array faithfully. The row is lost only
   when the host snapshot has no entry for that worktree path at all -- a first
   sync, a snapshot predating the close, or `resolveWorktreeId(path)` returning
   null during startup.

   Fix: admit a replaced worktree whose local row EXISTS (`Object.hasOwn`) rather
   than whose local row is non-empty. That is the same "the host is authoritative
   for what it knows, not for what it has never been told" rule the rest of this
   function already applies to tabs.

2. `defaultTerminalTabsAppliedByWorktreeId`. This was the only field in the
   function with no preservation branch: `:257-260` deleted the local entry for
   every replaced worktree and trusted the remote snapshot to carry it. The
   marker is write-once and is the sole guard on `applyDefaultTerminalTabs`
   (`worktree-default-terminal-tabs.ts:34`), so deleting it re-applies the whole
   default-tab template over the user's tabs. Removal belongs to the
   worktree-teardown path, not to a reconnect.

Why this shape rather than a revision counter: both are statements about what
absence means, local to one function, so they survive the SSH-v3 consolidation
unchanged. Neither adds a per-tab identity field.

Deliberately not done: the `hostUnknown` preserve branch is untouched, and no
close-suppression is added here -- that is the durable close tombstone, and it
belongs on top of this rather than mixed into it.

Before: 3 failed | 18 passed. After: 21 passed (45 across the wider merge,
default-tabs and initial-terminal suites).
2026-08-27 19:43:26 -07:00
Neil f864223723 fix(remote-workspace): carry defaultTerminalTabsAppliedByWorktreePath through the normalizer (#16747)
`defaultTerminalTabsAppliedByWorktreePath` is declared on the wire session
(`src/shared/remote-workspace-types.ts:16`), emitted by the renderer's export
(`remote-workspace-session-projection.ts:101-134`) and read back by its importer
(`:195`) -- but `normalizeRemoteSession` rebuilds the session as an object
literal and never copied it. Measured on a snapshot carrying `{ '/r': true }`:

    normalizeSnapshot(...).session.defaultTerminalTabsAppliedByWorktreePath
      => undefined
    remoteWorkspaceSessionMatchesSnapshot(withFlag, withoutFlag)
      => true

Two consequences. `getRemoteSnapshot` normalizes everything the relay returns
(`remote-workspace-relay-sync.ts:29`), so a client could never receive the flag
even though the write path sends `session` verbatim. And
`patchRemoteWorkspaceSession` short-circuits on
`remoteWorkspaceSessionMatchesSnapshot` (`:51`), which normalizes both sides --
so a change that only sets this marker was invisible to the dirty check and
could never be written at all.

The marker is the sole guard on `applyDefaultTerminalTabs`
(`worktree-default-terminal-tabs.ts:34`), so losing it re-applies the whole
default-tab template over the user's tabs.

This has never worked in either direction since the field was introduced.
`normalizeRemoteSession` predates `372469b7528` under a different path -- it
lived in `src/main/ipc/remote-workspace.ts` until `15e1ba3f84a` split it out for
max-lines, and at `372469b7528` it already copied all four sibling optional
fields and simply never gained a fifth. No test caught it because that commit
tested the projection round-trip (correct in isolation) while the normalizer sat
in another file with no test of its own.

The new test file closes that gap two ways: a case per consequence, plus a
`Required<RemoteWorkspaceSession>` fixture asserted key-for-key against the
normalizer's output. Because the normalizer is an object literal, the next field
added to the wire type and forgotten there is now a compile error in this test
rather than silent data loss.

Wire compatibility: safe, but not by the plain "new optional field" rule -- this
is an already-declared field that was silently stripped on every read. Both
readers treat it as optional; the relay is not a reader at all
(`src/relay/workspace-session-handler.ts:145-151` stores `patch.session` as an
opaque record and returns it verbatim), so no relay version can strip it. A
half-upgraded pair is one-directional: an old client still drops the key on read
and behaves exactly as today.

Before: 3 failed | 1 passed. After: 4 passed.
2026-08-27 19:42:12 -07:00
Neil 971d987c4b ci(e2e): trigger the Docker-SSH lane from SSH source and claim every gated spec (#16746)
The Docker-SSH e2e lane only ran when a PR's changed specs happened to include
`ssh-startup-exec-readiness.spec.ts` or `paired-startup-exec-readiness.spec.ts`.
Editing SSH source itself did not trigger it, and pruning either spec from a
route's list would have silently retired the whole lane. Meanwhile the sharded
lanes set no `ORCA_E2E_SSH_DOCKER`, so every Docker-gated spec skipped itself
while the shard still reported green -- the exact silent-skip shape
`docs/reference/ssh-reconnect-source-recovery.md` blames for four regressions
that reached users.

Separately, the modules that actually own direct-SSH workspace and tab restore
carry no "ssh" in their names, so the `ssh-terminal-source` route never reached
them. Measured on the real script before this change:

    printf '%s\n' src/renderer/src/hooks/remote-workspace-session-merge.ts \
      src/main/ipc/remote-workspace-snapshot-normalization.ts \
      src/renderer/src/lib/worktree-initial-terminal-seeding.ts \
      src/shared/remote-workspace-session-projection.ts \
      | node config/scripts/pr-e2e-source-routing.mjs
    => []

Three changes, all pinned by the executable gate contract:

- `hasSshSourceChange` derives an `ssh_source_changed` signal from the SSH
  routes themselves, plumbed pr.yml -> e2e.yml, so the lane triggers on source
  rather than on a spec name surviving in a list. One list, so the two cannot
  drift.
- A sibling `ssh-workspace-session-restore` route names the restore seams
  (`remote-workspace-*`, `worktree-initial-terminal-seeding`,
  `worktree-default-terminal-tabs`, `initial-terminal`) and routes them to the
  two restore specs -- a sibling rather than more paths on `ssh-terminal-source`
  so a tab-tombstone edit does not run the whole SSH terminal list.
- A new `test:e2e:ssh-docker` runner claims the remaining Docker-gated specs on
  the one VM that sets the flag, and the contract now fails by name when any
  Docker-gated spec is claimed by no runner. `ssh-docker-relay-perf` and
  `ssh-codex-display-artifacts-repro` are recorded exemptions (wall-clock
  budgets; needs a real remote codex binary) and the contract asserts each
  exemption still corresponds to a real gated spec, so a stale one cannot
  quietly excuse a gap. Lane timeout raised 35 -> 60 minutes for the added
  serial specs.

The lane's first act was to surface four latent bugs in a spec that had been
silently skipping. `ssh-docker-bulk-open-freeze-repro.spec.ts` is four call sites
out of date against `tests/e2e/helpers/terminal.ts`: `startDockerSshRelayTarget()`
is called with no argument though the helper dereferences `testInfo.workerIndex`
(a 100% failure, not a flake), `execInTerminal` gained a `ptyId` parameter, and
`splitActiveTerminalPane` gained a direction. It was invisible because it ran
nowhere and `typecheck:e2e` is red on main with 240 pre-existing errors, so four
more could not be seen.

The `testInfo` bug is fixed here -- correct on its own, and it removes one real
error from `typecheck:e2e` (240 -> 239). The other three are not, because they
are not argument plumbing: repairing them requires choosing which ptyId to
capture and which split direction to use, and both change what the repro
measures.

The spec is therefore added to the exemption list rather than repaired, for two
independent reasons recorded in the runner: it is a perf oracle, not a
correctness one (`SOFT_FREEZE_LAG_MS=2500` / `HARD_FREEZE_LAG_MS=5000` measured
under a deliberate 5-pane flood on a 420s budget -- the same rule already applied
to `ssh-docker-relay-perf.spec.ts`), and it is known-rotted. Repair is tracked in
stablyai/orca#16764. Applying an existing written rule to a sibling that plainly
meets it is consistency; inventing a new exemption to dodge a red would not be.

Three hardening fixes to the contract itself:

- Runner text is comment-stripped before the claimed-by-a-lane scan. A substring
  scan over raw text lets a spec merely *discussed* in a runner comment count as
  claimed -- the silent skip this assertion exists to catch, re-entering through
  the documentation. Not live today only because the existing comments write the
  spec names without their `tests/e2e/` prefix.
- An exempt spec must not be invoked by any runner. `unreachableSpecs`
  short-circuits the unclaimed check, so a spec could be documented as exempt
  while a runner still ran it -- an exemption that reads as coverage removal but
  changes nothing, leaving the lane red for a reason the file says it excluded.
  This is not hypothetical: adding the bulk-open exemption without removing it
  from the runner's spec list produced exactly that state, and this assertion is
  what caught it.

- The Docker-gate detector is now `/ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/`
  rather than one fixed string, so a double-quoted or `!==` spelling can no
  longer escape the contract.

`ssh-restart-tab-accumulation.spec.ts` is a new three-cycle restart fence
asserting tab-id set identity, not just the active pane's reclaimed ptyId as
`ssh-cold-activation-restore.spec.ts:241` did. It passes today; it was validated
by a negative control that injected one tab after cycle 1 and correctly failed.
2026-08-27 19:40:38 -07:00
Jinjing 2ac23c29be Add parent worktree selection when creating workspace (#15420)
* feat: select parent worktree for nesting when creating workspace

Enable users to pick a parent workspace in the composer's Advanced drawer,
organizing newly created worktrees hierarchically in the sidebar. The backend
validates lineage relationships and gracefully retries without the parent if
it becomes unavailable during creation.

* feat: select parent worktree for nesting when creating workspace

- Record app-picked parents as manual actions, not CLI-flag equivalents,
  ensuring the same user action carries consistent cleanup semantics across hosts
- Gracefully retry without parent if the selection goes stale, warn the
  user instead of failing
- Refactor create into modules: parent resolution, payload building,
  state merge

* Allow selecting parent worktree when creating workspace

- New parent worktree picker filtered by execution host and project
- Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership

* Allow selecting parent worktree when creating workspace

- Rename "Parent workspace" label to "Parent worktree"
- Filter candidates by execution host and project to prevent nesting across hosts
- Wire parentWorktreeId through composer state and creation request pipeline
- Update translations and add new copy for nesting-related messages
2026-08-27 19:38:01 -07:00
Brennan Benson 4f14d6d757 fix(mobile): stop held accessory keys after release under latency (#14219)
* fix(mobile): backpressure accessory key repeats

* fix(mobile): keep accessory repeats on the pressed terminal

* fix(mobile): serialize accessory key presses

* fix(mobile): preserve queued accessory taps

* fix(mobile): fence queued taps across reconnects

* fix(mobile): recheck queued tap delivery context

* fix(mobile): stop accessory repeats after IME send failure

* fix(mobile): pace repeated live-input edits

* fix(mobile): dispatch accessory taps without ack delay
2026-08-27 19:06:08 -07:00
Jinjing 3b4a15e609 test(editor): add copy button tests for check run details (#16925)
Add tests verifying that copy buttons render and correctly copy
content to clipboard for the output, annotations, and jobs sections
of the check run details panel.
2026-08-27 18:44:26 -07:00
Neil ebe6b559ef Prime changed native caches before test fanout (#16918) 2026-08-27 18:34:03 -07:00
Neil f96dfd5810 Extract CDP auxiliary commands (#16781) 2026-08-27 18:18:50 -07:00
Neil 3ed7796624 fix(fork-sync): address runtime repos by main worktree id, not repo id (#16876)
Repo-level fork sync (Safe Auto and the Sync Now button) passed `repo.id`
as the runtime worktree selector, so runtime-hosted repos always failed
with `worktree_id_requires_full_path`. Compose the repo's main worktree
id (`<repoId>::<repo.path>`) via a new shared `getRepoMainWorktreeId`.

Fixes #16447
2026-08-27 18:16:06 -07:00
Neil c90d9c4780 Extract GitLab dialog presentation parts (#16780) 2026-08-27 17:57:29 -07:00
Neil bdabb09a07 Extract mobile host and browser helpers (#16779) 2026-08-27 17:50:11 -07:00
Neil f48c038a93 Extract local PTY launch helpers (#16778) 2026-08-27 17:46:36 -07:00
Neil e16f3943fd Extract daemon launch path helpers (#16777) 2026-08-27 17:39:04 -07:00
Neil 39ac32f279 Extract terminal output scheduler subsystems (#16775) 2026-08-27 17:25:24 -07:00
Neil 86bedd1550 Split onboarding flow controller (#16774) 2026-08-27 17:14:41 -07:00
Jinjing b63c006ae9 Update Android download links to 0.0.46 (#16899) 2026-08-27 17:05:44 -07:00
Neil 2aec442f42 Split Linear issue service by operation (#16770) 2026-08-27 17:04:48 -07:00
Jinjing cb17d8dcda Add copy buttons to check run details sections
Adds clipboard copy functionality to annotations, output, and jobs
sections in the check run details panel. Includes a reusable
CheckRunCopyButton component and clipboard text formatting utilities
to prepare check run data for sharing.
2026-08-27 17:01:19 -07:00
Jinjing 9e0704b9d4 fix(ci): prepare node runtime for e2e build 2026-08-27 16:59:38 -07:00
Jinjing f2b25b776f Optimize nested toggle validation by sharing fence ranges across sibling
When validating nested details elements, computing fence ranges once and
reusing across siblings eliminates redundant body rescans. Export
MarkdownFenceRanges type and add precomputedFenceRanges parameter to
matchDetailsHtmlBlock.
2026-08-27 16:59:38 -07:00
Jinjing 1320a2a953 Support nested toggles as editable blocks with recursion guards
Previously nested details blocks were preserved as inert passthrough HTML.
Now, nested details that themselves meet editability criteria are opened as
editable toggle nodes. Recursive validation includes a 16-level nesting limit
to prevent stack exhaustion on pathological input. Refactors common markdown
editor test helpers into a reusable fixture module.
2026-08-27 16:59:38 -07:00
Neil 6e3e7e65ab Split Jira issue workspace presentation (#16769) 2026-08-27 16:57:29 -07:00
Neil f27ea956f2 Extract speech lifecycle and editor animation modules (#16767)
* Extract speech worker lifecycle helpers

* Split editor feature-wall animation
2026-08-27 16:49:35 -07:00
Brennan Benson 9a0a2b1c31 fix(orchestration): settle worker release without web layout state (#16842)
* fix(orchestration): tolerate missing terminal layout partitions

* fix(orchestration): handle legacy release without layouts
2026-08-27 16:39:19 -07:00
Neil a22a26008b Split Hermes cron run history (#16766) 2026-08-27 16:38:26 -07:00
Jinjing 551fbb9ac7 Revert "feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679)"
This reverts commit 249d93bc5d.
2026-08-27 16:32:35 -07:00
Neil 6c0de76ced Split port scanning and terminal helpers (#16765) 2026-08-27 16:29:05 -07:00
Neil 7ee8b5e1a6 Refactor lower max-lines modules (#16760) 2026-08-27 16:10:51 -07:00
Jinjing bb77a2b159 Add coverage for skill lock release and fix WebRTC test flakiness (#16846)
* test: add coverage for skill lock release and simplify WebRTC test

- Add test for cleanupReleasedSkillInstallLock handling rmdir races
- Improve error handling to cover all documented directory removal error codes
- Simplify WebRTC egress test to use localhost addresses consistently

* test: use network interface address for WebRTC egress probe

- Discover the first non-internal IPv4 address instead of hardcoding
  localhost, allowing the test to work in CI and varied environments
- Update proxy rules to use loopback designation for clarity
- Bind UDP socket to all interfaces (0.0.0.0) to receive on the
  discovered address
2026-08-27 16:09:43 -07:00
Neil 350423b7cb Speed up PR CI with path skips, native caches, and fewer shards (#16863)
* Speed up PR CI with per-job path skips and native caches

Skip git-compat, xterm, packaging, and shell jobs when their inputs are
unchanged, reuse the composite install action (including Windows node-pty
cache), skip compiling the Windows CLI launcher on a cache hit, and cut the
test matrix from 16x2 to 8x2 shards without dropping coverage.

* Widen PR job skip prefixes for orcad browser and live shells

Chrome session/tab modules and zsh/fish wrapper templates are inputs to
required jobs the classifier previously skipped. Include that implementation
graph so those jobs still run when the files they load change.

* Fix CI cache safety and required gates

* Build scriptless Windows addons explicitly

* Preserve node-pty Windows support prebuild

* Remove duplicated Windows launcher unit lane
2026-08-27 16:09:07 -07:00
Neilanddvpaa 074339478a fix(terminal): compose iPadOS Hangul by holding the syllable in the renderer (#13345) (#15480)
* fix(terminal): compose iPadOS Hangul by holding the syllable in the renderer (#13345)

Korean typed on an iPad with a hardware keyboard reached the PTY as separate
jamo: `한글깨짐` arrived as `ㅎㅏㄴㄱㅡㄹ...`. iPadOS fires no composition
events for it — each jamo is a plain keydown while the IME rewrites the
syllable in place in the helper textarea — so xterm consumes the keydown, sends
the raw jamo from `_keyPress`, and drops the composed `insertText` because
`_inputEvent` admits a composed insert only when no key is down.

The jamo keydown is handed to the system by a new bypass rule, and the syllable
it builds is held in the renderer until the IME proves it final. The PTY sees
one write per syllable and nothing is ever sent then retracted, so raw-mode
TUIs never receive DEL bytes they need not read as "erase one cell" and SSH and
relay sessions pay no round trip for them. `한글깨짐` is four writes and zero
DELs.

Sitting upstream of `xterm-bypass-policy.ts` rather than inside xterm is what
makes this work for Shift-typed double consonants: Orca's own Shift rule
already hides `ㄲ ㄸ ㅃ ㅆ ㅉ` keydowns from xterm, so a fix living in
CompositionHelper never sees them and every syllable starting with one — 깨 꿈
딸 빵 쓰다 짜다 — stays broken. That placement call is dvpaa's, from #13346.

Composition sessions are left alone entirely, so Chinese pinyin on the same
device keeps working; that state is derived from the existing composition
tracker rather than latched, so a session that never ends cannot disable the
pane. The bypass claims jamo only — a Cyrillic or kana key would lose its
keydown, keypress and `input` alike and reach the PTY as nothing.

Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com>

* refactor(terminal): drop the dead session check from the iOS preedit input guard

`isCompositionOwnedInput(event) || options.isCompositionActive()` could never
take its second branch. The composition tracker's own `input` listener runs
first on the same element and clears its active flag for every input except
`insertCompositionText` — which is exactly the first disjunct. Verified by
construction: instrumented to throw on the combination, nothing in the renderer
suite (24k tests) reached it, and five deliberate attempts to build one, via a
resumed preedit and a post-compositionend insert, all failed to.

Reading composition ownership off the event alone also makes the decision
independent of listener registration order, which the previous comment at the
call site claimed to depend on. It does not: swapping the tracker and the
preedit controller leaves every test passing. The comment now states the one
coupling that is real — the controller stops propagation on `input` while a
syllable is held — without asserting a behavioral dependency that does not exist.

`isCompositionActive` remains the gate on opening a hold, where it is pinned.

* fix(terminal): settle iPad Hangul by diffing the field, not assuming it grows

The hold released a syllable only when the textarea tail grew past it and
still started with it. Korean batchim migration breaks that: a device
capture on iPadOS 26 shows `깨` + `ㅈ` rewritten to `깾` — a different
codepoint, not an extension — and only becoming `깨주` once the next vowel
decides where the `ㅈ` belongs. The prefix check failed there, the hold
stopped advancing, and `깨쥠` reached the PTY as one chunk on blur.

Locate the IME's edit with a prefix diff of the two field states instead.
Everything before where it began rewriting is settled: the batchim question
for those syllables is already answered. Still hold-and-commit, so no DEL
ever reaches the pty.

The capture is now the fixture, replayed both verbatim and as keystrokes.

* fix(terminal): keep the iPad Hangul hold open across the IME's erase rewrite

Backspace can decompose a held syllable as deleteContentBackward then a
replacing insertText, the same shape the IME uses to grow one. The hold
closed on the emptied half, so the replacement landed with nothing held:
typing 한, Backspace, ㄹ put a bare `ㄹ` on the wire and dropped 하.

The empty field now collapses the hold instead of closing it, and the
Backspace that finds nothing held is the one that reaches the PTY. An
`imeWrote` flag keeps an erased hold from resurrecting its opening jamo.

Also diff the field as NFC. Decomposed Hangul grows by appending jamo,
which the common-prefix diff reads as the previous syllable settling, so
an NFD source emitted one bare jamo per keystroke. The recorded device
trace is NFC, where normalization is a no-op.

* test(terminal): cover Japanese, Hanja and Greek on the iPad Hangul path

The coexistence suite proved pinyin and a short list of non-Hangul keys.
Widen it: a kana-to-kanji session, a Hanja lookup over a live preedit, a
digit that ends a held syllable as literal text, and Greek, halfwidth
kana and accented Latin among the keys the bypass must not claim.

---------

Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com>
2026-08-27 16:06:43 -07:00
Neil 64f537f442 test(terminal): pin that Hangul is two cells under every unicode provider (#15477)
* test(terminal): pin that Hangul is two cells under every unicode provider

#15192 turned out to be an upstream Antigravity CLI defect, but the
investigation re-litigated Orca's Hangul cell width three separate times
before ruling it out. This makes that negative result durable.

The first test closes a real gap rather than restating the others. Nothing
verified which provider actually ends up active in production:
pane-lifecycle.test.ts asserts activeVersion=11, but its terminal mock has no
_core, so activateOrcaTerminalUnicodeProvider can only ever take the fallback
branch there. This asserts on a real terminal, in pane-lifecycle's order, that
the Orca provider is reached.

The sweep matters because it is what makes the width theory unavailable
rather than merely unproven: all 11,172 precomposed syllables budget two
cells under v6, v11 and the Orca provider, against both wcwidth and the
packed charProperties bits. Even total activation failure leaves them wide.

Also pins the wide-cell test oracle's disagreement with xterm on conjoining
jamo U+1160..U+11FF. Only decomposed Korean reaches them and no fixture
writes NFD today, so nothing mis-asserts now - but a repaint test would
silently assert against a wrong oracle if anyone added NFD text.

Refs #15192

* test(terminal): drop the version-sensitivity list as redundant

Its output is a strict subset of the oracle-divergence test's, measured a
different way, for about one bit of information across twenty lines.

Refs #15192

* test(terminal): assert both widths in the Hangul oracle divergence, drop the pane-lifecycle-order claim

The oracle divergence only asserted that xterm and the fixture disagree, not how:
widening the fixture's jamo range from one cell to two left the expected list
byte-identical, so the tripwire it exists to be would not have fired. Record both
widths in the run key.

Test 1 claimed to pin pane-lifecycle's activation order, but it mirrors that order
rather than importing it — deleting the call at pane-lifecycle.ts:88 or moving it
before loadAddon leaves it green (pane-lifecycle.test.ts covers both). Retitled to
what it does pin: that xterm's live _core shape still reaches the non-fallback branch.

Also oxfmt.
2026-08-27 16:02:56 -07:00
Brennan Benson 81ae98e10d fix(mobile): honor host worktree create retention (#16342)
* fix(mobile): honor host worktree create retention

* fix(mobile): cover malformed worktree retention policy

* fix(mobile): fail closed on malformed retention policy

* fix(mobile): fail closed on missing dedupe ttl
2026-08-27 16:02:38 -07:00