`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`.
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.
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).
`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.
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.
* 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
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.
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
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.
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.
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.
* 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
* 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
* 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>
* 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.
* feat(agent-status): add the pane agent identity resolver
Four ladders answer "which agent is in this pane" independently — the tab icon, the
open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they
disagree. Two consult the terminal title before the launch record, so a string Orca parsed
outranks a fact Orca owns.
resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering:
1. Evidence is ranked by how directly it observes the process; a display title is last.
2. Each observation carries the runId of the agent run it describes. Evidence from a superseded
run is INELIGIBLE, not merely outranked.
Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming
B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) —
identical signals, opposite correct answers. Run ids make them different facts: in the bug both
belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as
a test asserting the two produce opposite answers from the same evidence.
Missing run ids are treated as eligible. Absence means "this peer does not publish them", not
"this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so
pane-scoped consumers cannot inherit another pane's agent.
No consumer imports this yet; each migrates separately with its own evidence.
Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the
run filter fails 3.
* fix(agent-status): close three resolver contract holes found in review
**Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the
first match, so two live hooks naming different agents were settled by input position — the exact
property this resolver exists to remove. The original order-independence test only used DISTINCT
sources, so it never exercised it. Conflicting same-class evidence now returns null with
`ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two
hooks disagree is worse than saying nothing.
**A bare numeric runId collided across authority restarts.** `incarnation` is a total order only
within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per
authority instance, so a restarted host counting from its own floor would report `1` and match an
unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT
authority is treated as incomparable — kept, like an absent key — rather than as stale.
**Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse
unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker
evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed
string even by reordering its inputs. Display surfaces omit it and are unaffected.
Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was
lost when this branch was rebased.
Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping
the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used
`incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses
differing incarnations.
The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here:
it needs an observation timestamp the evidence type does not yet carry. Recorded rather than
silently dropped.
* fix(orchestration): route @agent messages by resolved identity, not terminal title
`@claude` picked its recipients with `buildAgentNameRe('claude').test(title)`, so any pane whose
TITLE contained the word received Claude's messages. Terminal titles carry task text, and people
describe agent work in them, so this is the ordinary case rather than a contrived one: the
recorded title "Switch Claude and Codex off the load balancer… - grok" is a Grok pane that
received both @claude and @codex. Misdelivered instructions, not a cosmetic slip.
The cause is that `RuntimeTerminalSummary` carried no identity at all — `title` was the only
identity-ish field on it, so routing by title was the only option available. Fix the input:
- `RuntimeTerminalSummary.agentIdentity?: TuiAgent` — optional, host-resolved from launch and
foreground-process evidence the host owns, with the title ranked last and contributing only
when the evidence parser finds an unambiguous name. A title that merely mentions an agent
yields no evidence, which is the whole point.
- `resolvePublishedPaneAgentIdentity` in `src/shared` rather than inside the runtime class, so
the decision is testable without a runtime and so routing, delivery and the UI cannot drift.
- Groups match `agentIdentity`; the title matcher and its bespoke Cursor predicate are deleted.
Unknown fails closed. `agentIdentity` is absent when the host predates the field or had no
evidence beyond the title, and delivery is an action: not delivering is visible and recoverable
(the sender sees no recipients), while delivering to the wrong agent is neither. The optional
field is additive, so an old client simply ignores it (wire rule 1).
This is also the first real caller of the evidence parser and the identity resolver.
Tests: 27 in groups, 8 for the publisher, 3 RPC fan-out cases updated to the new contract. The
`@cursor`-must-not-match-"text cursor blink" hazard is now excluded structurally instead of by a
per-agent predicate.
Verified non-vacuous by mutation: swapping the process/title ranks fails 2 publisher assertions,
and reverting groups to title matching fails 15 of 27. One earlier mutation silently failed to
apply after formatting reflowed the block — the file was checked before trusting the result.
* perf(runtime): reuse terminal title during summary build
* fix(orchestration): refuse title evidence when publishing identity for routing
Rebuilt on current main so this carries the hardened parser from #16148 and the corrected
resolver from #16157 (authority-scoped run keys, no order-dependent duplicate resolution).
Applies the resolver's new `minimumSource` floor at the publisher. What this publishes authorizes
an action — routing decides which real agent pane receives a message — so ranking title last is
not enough; the floor removes it from consideration entirely, and no amount of reordering by a
caller can bring it back.
The trade, stated because it is a real capability loss: a hook-less agent over SSH that Orca did
not launch, and whose foreground process the host cannot read, is no longer addressable by @agent.
Accepted because a message delivered into the wrong agent's prompt is unrecoverable while an
undelivered one is visible — the sender sees zero recipients. Whether real panes actually carry
launch/foreground evidence is the open question, and is what live validation must answer.
* fix(pty): preserve agent identity on daemon reattach
* fix(runtime): retire stale pane agent identity
* chore: normalize runtime types formatting
* fix(agent-status): identify a pane from its own hook, not from how it was started
Two defects, one cause: identity was inferred from the outside instead of read from the agent.
**Hook evidence was never plumbed in.** The publisher considered `process`, `launch` and `title`
and contained zero hook references — while the resolver ranks `live-hook` first. The top rung of
the ladder was never connected.
That made identity depend on Orca having launched the agent. Most agents are started by typing
`claude` or `codex` at a shell, which leaves no launch record. On macOS the foreground process
still names them, so the gap was invisible. On WSL the Windows host reads the foreground process
as `wsl.exe` — the distro wrapper, not the agent inside it — so those panes had no signal at all
and became unaddressable by `@agent`.
A hook is the agent reporting itself, so it survives both: no launch record needed, and no
dependency on reading a process across the WSL boundary.
**`launch` outranked `completed-hook`.** Ranking is now by TENSE rather than by how authoritative
a source sounds:
present: live-hook > process
past: completed-hook > launch > sleeping-session > sibling > title
A launch record is an event, not a state — it stays true after the agent exits, which is why a
pane reused after closing its agent kept reading as the old one. A completed hook at least proves
the agent actually ran in that pane; a launch record only proves Orca tried to start one.
Neither rank was covered: all 392 existing tests passed unchanged after reordering. Mutation now
fails 2 on the old order and 4 with hook evidence removed.
Known remaining gap, deliberately not papered over: a hand-started WSL agent with no managed hooks
has no identity signal at all. Restoring a title guess there would reinstate the misdelivery this
PR exists to prevent.
* fix(orchestration): restore title as the last resort, not a forbidden source
An earlier revision passed `minimumSource: 'launch'` so routing could not see a title at any rank,
reasoning that a display string must never authorize a write. That conflated the evidence parser
with the raw substring match it replaced.
`buildAgentNameRe('claude').test(title)` was the misdelivery. `collectAgentTitleEvidence` returns
null on exactly those shapes: "Review the Claude session-history fix" on a Codex pane yields
nothing, and "Switch Claude and Codex off the load balancer… - grok" yields grok from its owner
suffix. Ranking title last is therefore sufficient; refusing it is not necessary.
Refusing it had a real cost. An agent a user starts by hand inside an Orca WSL terminal has no
launch record, no readable foreground process (the Windows host sees `wsl.exe`, not the agent in
the distro), and — until managed Codex hooks install there — no hook either. An unambiguous title
was the only thing left, and dropping it made that pane unaddressable by @agent where the previous
code could reach it. That is a regression, and most agents are started that way.
End-to-end coverage added at the routing layer with title allowed: @claude still does not reach a
Codex pane whose task text names Claude, @codex still does not reach a Grok pane whose task text
names Codex, and a pane identified only by an unambiguous title is reachable again.
* revert(agent-status): keep launch above completed-hook until run keys exist
Reverts the tense-based reorder from this branch. The reasoning behind it was sound as far as it
went — a launch record is a past event, not an observation, which is why a reused pane kept reading
as its previous agent — but it fixed one staleness by opening a worse one.
A completed hook is past tense too, and without an agent-run key it never expires at all. Ranking
it above `launch` lets a stale hook from a previous agent outrank the launch record Orca stamped
for the process running NOW. pane-agent-owner.ts already says this in its own comment: "Ranking
launch/live-hook above the completed/sleeping records keeps a genuine pane on its real agent and
stops a stale record from hijacking it."
The reorder belongs with authority-scoped run generation, which is what makes any past-tense
evidence expire. It is staged in the migration plan rather than shipped here.
What this branch keeps: hook evidence feeding pane identity (so an agent a user starts by hand is
identified from its own report rather than needing a launch record), and title restored as a
genuine last resort behind the evidence parser.
* fix(runtime): guard the pane key so terminal.list survives a non-UUID leaf
`makePaneKey` throws on a leaf id that is not a UUID. The hook-evidence lookup called it unguarded
inside `buildTerminalSummary`, so a single such leaf took down `terminal.list` for the whole list
rather than degrading that one pane — 136 tests across 5 files, and the native code-quality gate
tripped separately on a duplicate test title.
Both were mine, and both were caught by CI rather than by me: I ran the focused suites before
pushing instead of the affected directories.
* fix(runtime): declare published terminal agent identity
* fix(runtime): demote completed hook identity evidence
The Chat UI setting described itself in terms of "supported agent terminal panes" without naming them, and an unsupported agent falls back to the terminal silently — no toast, no toggle, no explanation. A user on OpenCode reported this as a bug in Discord.
Adds a "Supported agents:" icon row under the toggle, matching the existing StatusBarUsageEmptyCta legend pattern, driven by the same list the availability predicate uses so it cannot drift from actual support. Icons carry role="img" plus a tooltip for identification. Also adds the missing openclaude/omp settings-search keywords.
* fix(native-chat): separate image paths from following prompt text (STA-4993)
Native Chat image send wrote a framed path and then the prompt with no
separator, so after the TUI unwrapped the paste the two glued together
(`…pngdescribe`). Put a trailing space after the frame when text follows,
share that rule with clipboard image paste and terminal drops, and split
the image send path out so the runtime send file stays under the line cap.
* refactor(native-chat): keep image separator fix focused
* fix(native-chat): keep consecutive image frames bare
* test(runtime): update export parity for terminal degradation
* test(native-chat): pin image frame separator contract
* fix(workspaces): add collision-safe worktree identity
* fix(workspaces): read worktree metadata per host and repair ambiguous identities
The canonical identity store landed write-only: getWorktreeMetaForHost had no
production callers while setWorktreeMetaForHost kept the legacy projection only
for the first known owner, so a second host's edits persisted and were never
read back. Wire the listing paths through host-qualified reads.
An ambiguous alias was also unrecoverable — reads returned undefined and writes
threw forever, and the throw escaped the detected-worktree loop, emptying the
whole repo's sidebar. Fail open onto the most recently active instance instead.
- collapse ambiguous aliases deterministically and persist the repair
- reclaim identity rows in the metadata GC so they cannot outlive their locator
or resurrect onto a worktree recreated at the same path
- drop every host's rows when a locator is removed outright, not just the owner's
- honour an explicit instanceId so the stale-lineage rotation guard still works
- scope a rename to the moving host; other hosts keep their own locator
- prefer the project host setup matching the repo's own execution host, so a
repoId registered on two hosts no longer stamps the wrong one durably
- reject an unencoded `|` in a host id, the invariant the alias delimiter needs
- drop the never-populated hostGeneration from the canonical key
* fix(workspaces): close remaining identity review gaps
* fix(workspaces): close remaining review gaps
* fix(workspaces): address review and CI regressions
* test(workspaces): update host-qualified metadata expectations
* fix(workspaces): preserve ambiguous identity records
* fix(workspaces): snapshot metadata during listing
* test(workspaces): mirror listing metadata snapshot in windows fixture
* fix(workspaces): preserve identity routing for metadata writes
* fix(workspaces): scope stale metadata cleanup by host
* fix(workspaces): rekey identities on SSH readoption
* fix(workspaces): fail closed for ambiguous board ids
* perf(workspaces): snapshot metadata across catalog listing
* fix(workspaces): retain neighboring manual order updates
* test(workspaces): cover ambiguous board id index
* fix(persistence): harden host-qualified worktree metadata
* refactor(shared): split project host setup lookup
* refactor(workspaces): simplify host-qualified metadata
* fix(mobile): report composing state from accessory backspace
The accessory path edits the field itself and then mirrors it, but called
applyLiveInputMirror with two arguments where the signature takes three. The
local option type declared it 2-ary, so the type checker never saw the drop.
An omitted composing flag is not "not composing": it selects the Android-only
heuristic that holds the trailing non-ASCII run. A pinyin preedit is plain
ASCII, so the heuristic reads it as committed text and sends it. Typing
`ni hao`, tapping accessory Backspace, then picking a candidate put `ni ha` on
the PTY before the commit, giving `ni ha你好`.
Korean survived this by accident - the non-ASCII heuristic re-derives the
correct hold for Hangul - which is why it went unnoticed.
The held range is the fact the mirror needs, and it is already in scope.
Refs #13345
* fix(mobile): preserve accessory IME report provenance
Rolldown miscompiles `export let fn = noop` by const-folding initializers
and dropping setters. Refactor to use null-initialized impl vars behind
wrapper functions instead, and add test to prevent regression.