Split out of #17170, which now carries only the xterm composition-overlay work.
Codex and Claude draw an all-dim, full-row ghost placeholder. The opaque preedit
overlay reproduces the committed row tail it covers, so without this the ghost is
repeated to the right of the composing syllable instead of staying masked. The
binding keys off the `.xterm-composition-remainder` class that #17170 adds and
hides it through CSS while a composition owns a structurally verified placeholder
row — bold prompt glyph plus a dimmed model footer below a blank gap for Codex, a
frame line above the prompt for Claude. Arbitrary dim output, shell lookalikes,
and any row carrying typed text keep their tail visible.
readTerminalCursorLineContext moves from src/main/daemon to src/shared because the
renderer now needs the same reader the daemon uses; the move is import-only.
Depends on #17170.
* fix(terminal): render IME caret without placeholder overlap
* fix(terminal): preserve dim mid-line composition tails
* fix(terminal): keep IME caret visible at row edge
* fix(terminal): harden IME overlay lifecycle and layout
* test(terminal): type final-cell layout mock
* fix(terminal): keep final-cell IME anchor on-screen
* fix(terminal): bind IME masking to composer ownership
* fix(terminal): bound IME placeholder session ownership
* fix(terminal): track latest IME placeholder session
* test(terminal): share IME session event fixture
* fix(terminal): keep both writers of the IME candidate anchor in agreement
`textarea.style.left` has two writers: xterm's patched CompositionHelper and
Orca's terminal-ime-candidate-anchor.ts. The anchor module listens on
terminal.element, so within a composition event it writes after xterm's textarea
listener and reverted the final-column clamp the patch had just applied.
Moving the clamp into the anchor module and dropping the patch hunk does not fix
it, and the rendered e2e caught that: CoreBrowserTerminal.ts:444 drives
updateCompositionElements from onRender as well, so xterm re-asserts the textarea
position on every repaint, with no composition event for that module to hear. The
anchor survived only when no render happened to follow — measured as a flake at the
final column, 1561.28px against a 1557px screen edge, the fully unclamped value.
So both writers now compute the same clamp. The patch keeps it, because it is the
writer on the render path and already holds cursorLeft, maxWidth and the preedit
bounds. The anchor module applies the same one, so its composition-event write no
longer reverts the correction in the window before the next render. Both halves are
individually necessary and both are mutation-tested.
Also restores _getRowRemainderText's expression from main: translateToString(true,
x, line.length) and translateToString(false, x, getTrimmedLength()) are the same
call, since upstream does endCol = min(endCol, getTrimmedLength()) under trimRight.
Adds the two missing tests — one installing both anchor writers in a single rig, one
driving a render under an open composition — plus disposal cleanup and clamp-bound
coverage, and moves the Codex/Claude placeholder mask to a follow-up PR.
* ci: gate PRs on a real input method, and prove the lane engaged one
No job on the PR gate has ever run a real input method. pr.yml and e2e.yml are
ubuntu-latest with CDP `Input.imeSetComposition`, which is a synthetic
composition; the only job that drives ibus-hangul through xdotool is
terminal-ime-e2e.yml, and it is schedule + dispatch only. A PR could turn the
real-IME path red and merge green.
Route IME source to that lane from pr.yml through the existing
pr-e2e-source-routing mechanism, so it runs on IME-touching PRs and nothing
else. The lane stays out of verify.needs — advisory, like `e2e` — because its
reliability is known only from nightly main runs. Deliberately no
continue-on-error: that reports green and hides the signal.
The harness fails open in ways that all look like success: Playwright reports a
skipped test as a pass, so an unset ORCA_E2E_NATIVE_IBUS_HANGUL, a renamed test,
or a session with no engine all exit 0 having exercised nothing. The specs now
append an engagement receipt only after observing real composition events, and
the runner requires one per expected test before the lane may report success.
Also drop the native spec from changed-e2e: it was already routed there by its
own filename, where it self-skips for want of an ibus session and reported that
skip as coverage.
* ci: let the real-IME step report even when the synthetic step failed
PR #17347 switched the reveal helper from window.show() to
window.showInactive() and updated the thrown message, but left the
unit test's regex/title matching the old show() wording — failing
deterministically in CI (which builds against current main) while
passing on any stale checkout that predates #17347.
* fix(remote): focus host-delegated split panes
Return the authoritative leaf identity from terminal.split, record viewer-local focus intent behind the captured pairing revision, and replay the mirrored layout before focusing the exact pane. Preserve old-host fallback and prevent delayed split responses from stealing focus after the viewer moves away.
Add deterministic runtime, renderer, concurrency, compatibility, and headed paired-Electron coverage for Cmd+D, header splits, and immediate PTY input routing.
Fixes#16510
* fix(remote): preserve split focus across tab groups
Resolve the initiating source tab and leaf from the remote PTY, while keeping the viewer's current focus as a separate anti-steal baseline. This lets context-menu/header splits from non-focused group tabs focus their result without allowing delayed responses to override a later navigation.
* test(remote): drive split focus with key events
* test(remote): use the platform split shortcut
* fix(remote): fence concurrent split focus intent
* fix(remote): harden split focus ordering
* fix(remote): preserve split focus after runtime refactor
* fix(remote): fence stale split focus gestures
* test(remote): keep split focus regression within line budget
main (#17206) cut local-pty-provider.ts from 1584 to 194 lines, moving
getForegroundProcess into local-pty-foreground-inspection.ts. This branch had
rewritten that same method into an observing form, so neither merge side was
resolvable on its own.
Ported rather than resolved:
- local-pty-foreground-inspection.ts now exports observeLocalPtyForegroundProcess
returning LocalForegroundObservation. getLocalPtyForegroundProcess stays as the
collapsing wrapper, so every existing caller keeps the exact legacy value.
- inspectProcess moves onto LocalPtyProvider and consumes the observation, which
keeps the evidence type in local-pty-process-evidence.ts and out of the
inspection module's import cycle.
The verdict vocabulary crosses the new module boundary unchanged: foreground
reads answer observed / unverifiable, children answer live / unverifiable /
exited, and a degraded or thrown read stays unverifiable rather than collapsing
into an exit.
* fix(browser): close guest-owned split tab
* fix: check sourceId before toggling floating panel on close
The empty-panel toggle is the ambient fallback only. Guest-initiated
closes (with sourceId) target the main workspace and should not toggle
the panel.
* test(browser-split-shortcuts): remove terminal-mirrors close test and un
Removes test case that verified Cmd+W closes guest-owned browser splits when
active-tab mirrors point to a terminal, along with the helper function and
unused fixture properties that only that test required.
* test(cross-version-wire): derive skew expectations from the baseline under test
The cross-version wire job pairs current code against whichever release tag is
newest, so a hand-written "the old side does not have X" assertion expires by
itself: v1.4.192 was the first tag containing the SnapshotStart `terminalOwner`
field, and cutting it turned the new-client/old-server pairing red on unrelated
pull requests with no code change anywhere.
Read what each build publishes from that build. Each host is now paired against
a client of its own version to produce a reference, and the skewed pairings are
compared against that reference, so the expectation is whatever the release
actually shipped. The same class of assertion in the agent-session suite —
"the old build advertises no structured capability and registers no structured
method" — becomes "each build's advertisement agrees with what it registers",
and the "client too old to know this capability" is derived by removing the
capability from the baseline's own list.
The guard is unchanged in strength: a field the old host still publishes may not
be dropped, skew may not change what a host puts on the wire, and a new pairing
asserts the oracle still stalls when a peer cannot decode an opcode the other
side sends.
* test(cross-version-wire): exercise release structured methods
* test(cross-version-wire): load the registered method manifest
* test(cross-version-wire): assert execution, not registration, on both host gates
The release-shaped checkout gate accepted any reply that was not
method_not_found, so a registered-but-throwing handler passed it. The
capability gate asserted a shared host spy had been called at all, so the
second method mapped to that spy could stop reaching the host unnoticed.
* test(cross-version): make the release-shaped skew cover the whole agent-session manifest
The release-shaped checkout is the only place the "registered means usable"
claim is executable today — the baseline release registers none of these
methods — and it was exercising one of sixteen. A handler registered and
returning an execution error passed the suite.
- Declare each method's result in the manifest, so "answered" is the contract
rather than "did not say method_not_found".
- Give each build a seam to install a host into its own module slot; a release
checkout has its own copy, so the working tree's host was never this
dispatcher's, and every host-backed method answered
structured_agent_session_unsupported — the capability gate's own words.
- Run one execution contract over both skews instead of two divergent loops.
- Pair the AI Vault never-called spy with a positive control; renaming the
runtime method it watches left it green.
---------
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
The completion monitor spent an evidence-less host's answer as a confirmed
process exit and fired "agent done" while the agent was still running.
A daemon on the older protocols (11..26, retained across in-place app updates)
publishes the same `zsh` + `false` for a genuinely idle pane AND for a
foreground read that fell back to the shell title. It has no field to separate
them. A current daemon calls that second state `unverifiable`, which is the
proof the bytes are ambiguous rather than the old daemon being wrong.
Concluding the agent finished IS an action on absence — the user's answer to it
is to walk away — so it now reads through
`readPtyProcessInspectionEvidenceForAbsenceAction`. The plain reader stays on
the line above for recognition and error backoff: that host ANSWERED, so it must
not be charged as a failed inspection, and its one positive must still be
tracked. Collapsing the two reads into one call costs such a pane ~10x its watch
rate via the >=10s inspection error backoff; that separation is now pinned by a
cadence test rather than left to comment.
Accepted cost: process-exit completion goes dead on pre-v27 daemon panes. Hook
and title sources are unaffected, so completion still reports through those.
Reporting an agent finished while it runs is worse than not reporting completion
through one source when we genuinely cannot tell.
Also collapses the third open-coded copy of the rule (the workspace-cleanup
liveness probe) onto the shared reader, and adds the STA-5901 consumer-agreement
enumeration: 570 inspection shapes, 0 divergences between the shared reader and
the open-coded rule it replaces.
* feat(native-chat): port structured Codex sessions from restructure-recovery
Rebuilds the desktop structured native-chat implementation from
brennanb2025/native-chat-restructure-recovery (tip 4e31c08db3) on top of
current main as a single commit, scoped to the local Codex path.
Ported:
- Structured agent-session core: durable record store + single-writer lease,
canonical journal, agent-session wire host/attach/eviction/subscribers,
`agentSession.*` RPC surface (registered via ALL_RPC_METHODS; host-side
mobile allowlist included for wire compat), pty write gate, transcript
additions, and the Codex app-server adapter/launch resolution.
- Renderer: NativeChatStructuredSession view/composer stack, structured
launch path with the single-flight guard, local structured session tabs
sync, activation gate + structured inventory (read-only
`agentSession.handoffStatus` probe), agent-session tabs in the tab strip,
AI-vault structured session activation, and the settings pane with the
parent Experimental Chat UI toggle plus the nested "Use updated structured
native chat" toggle. New sessions require both flags, agent codex, no
prompt, and a local non-WSL, non-Windows-host execution host
(structured-native-chat-availability).
- Fixes 72c013cea6 (verified Codex launch recovery), 8ddbaf5e3d (defer
native terminal view switching affordances), and 4e31c08db3 (release the
launch gate after a visibility retry) with their regression tests,
including the third-launch-after-retry guard case.
- Cross-version agent-session wire test + CI lane, packaging entries
(proper-lockfile, agent-tooling asar excludes), and the wire-compat doc
section.
Deliberately not ported: mobile/ changes, the Claude structured runtime
(only the claude-transcript-branch-proof and claude-structured-owner-identity
leaf modules remain, backing the kept TUI-recovery arms), the terminal↔chat
adoption/handoff flow (`agentSession.adoptTerminal`/`requestHandoff`, the
handoff request engine, TUI adoption machinery, orca-runtime adoption
methods), renderer switching affordances and their dead leftovers, the
hook/subagent-status refactor cluster, and unrelated branch changes. The
crash-during-acquisition recovery path (restart handoff adjudication,
restore/reverse re-acquire, lease schema handoff keys) is kept because every
plain direct launch depends on it; a trimmed handoff coordinator exposes
only status/restore/close.
Branch edits that targeted files main has since split (ipc/pty.ts,
worktrees.ts, rpc/methods/terminal.ts, useIpcEvents, pty-connection,
store/slices/terminals.ts, runtime-types, web preload) were re-applied to
the split modules, preserving main's newer logic (Windows CIM fallback,
browser tab close rework, cold-restore resume flow, dispatcher threading).
Known seam: the mobile clipboard image-provenance CONSUMER gate ships
(agentSession.send refuses unproven mobile image refs with
agent_session_image_untrusted) but the producer hunk in
rpc/methods/clipboard.ts stays with the unported mobile cluster, so mobile
image sends into structured chat fail closed until that side ports.
* fix(native-chat): trust only authenticated local image uploads
* fix(build): preserve Windows process-tree patch application
* test(windows): include process creation time in addon fixture
* fix(build): run windows-process-tree node-gyp from the physical package dir
gyp expands the node-addon-api dependency by probing node, whose cwd
resolves to the package's physical directory in the store, so the emitted
target is a store-relative ../../../../node-addon-api@... hop. gyp then
resolves that hop against the rebuild cwd; from the node_modules
symlink/junction it escapes the store and configure fails with
"node_addon_api.gyp not found" (run 32999886072).
Rebuild from realpath(package dir) so both bases agree, matching how the
package manager itself runs native install scripts. The regression test
replays gyp's expansion+resolution against the planned cwd and fails
without the fix.
* fix(native-chat): keep chat tabs visible through terminal closes and empty-worktree launches
Two proven blockers in the native Codex tab contract:
closeTerminalTab pre-empted the canonical unified close. With one terminal
left it deactivated the worktree on a terminal/editor/browser-only check,
blanking a workspace that still held a renderable agent-session tab; with
two or more it pre-picked a successor from terminal entities only,
re-stamping the group active before closeUnifiedTab's MRU/neighbor repair
could land on the chat tab. Successor choice now defers to the unified
contract whenever the terminal has a unified row, and deactivation is
gated on the unified renderable count (matching leaveWorktreeIfEmpty),
with the legacy pre-pick kept only for terminals without a unified row.
A structured session created on an empty worktree was published into the
host's headless group while preserveLocalLayout froze the local layout,
leaving the tab in store but permanently off screen. A preserveLocalLayout
owner now always takes client-owned placement — repairing a rendered
leaf whose group record is missing, or materializing a rendered group on a
truly empty worktree — and applies the client-derived layout repair while
still rejecting host-authored layout.
Regression tests drive the real store through closeTerminalTab (git
worktree and folder workspace) and the real snapshot applier for the
empty-worktree adoption states; all fail without the fixes.
* fix(native-chat): close stale turns and retry rejected sends
* fix(native-chat): retire hosted rows on structured tab activation
* fix(native-chat): preserve rpc defaults across main merge
* chore: format remote wire compatibility guide
* test(native-chat): cover retry after unconfirmed send
* fix(native-chat): reload outbox on session switch
* docs(settings): disclose structured chat platform limits
* fix(native-chat): await Codex launch-home preparation
* fix(codex): align child-process allowlist with async trust bridge
* test(identity): update inventory for tab surface refactor
* fix(windows): preserve process-tree CRLF patch sources
* fix(native-chat): anchor an unmatched chat echo where it was sent (#16117)
* fix(native-chat): anchor an unmatched chat echo where it was sent
The reported symptom was old user messages replaying below every new turn, so the
conversation read as scrambled. The cause was not that the echo failed to match a
transcript row. Claude consumes a mid-turn send through a `queued_command`
attachment and writes no `type:"user"` record for it, so some echoes can never
match, and no amount of matching will change that. The cause was WHERE an
unmatched echo rendered: buildMobileNativeChatTransientData appended every pending
item after the entire transcript, so it re-read below each turn that landed
afterwards.
Render each echo directly after the transcript row it was sent against, using the
baseline the send already captures. An unmatched echo is then at worst a duplicate
in the right position rather than a scrambled one, and it stays visible. Echoes
sharing an anchor keep send order; a send with no baseline, or one whose anchor
folding dropped, still falls back to the tail.
Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an
echo can never match, then removing it, loses the user's own text for a message
the agent did receive, and it cannot fire in the common case anyway - measured
drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing
gap: the count pass has no baseline-tail guard, unlike the glue pass, while
`messages` is a 40-row window that head-trims, resets on reconnect and grows at
the front on loadEarlier, so a false landing there would license deleting a
DIFFERENT outstanding message.
That count-pass gap is real and left for a separate change; anchoring makes its
worst case a duplicate in place rather than a scrambled conversation.
* fix(native-chat): preserve folded echo anchors
* fix(native-chat): preserve forward-folded echo anchors
* fix(native-chat): keep leading folded echoes in place
* fix(workspace-cleanup): show git status for every row (#16690)
* fix(native-chat): refuse structured chat on every Windows execution path
canUseStructuredNativeChat only refused win32 when a project runtime
resolved, so folder-workspace keys (and other keys with no project
runtime) failed open into structured chat on Windows. Fail closed on
win32 unconditionally after the host check, matching the settings copy:
local macOS/Linux only; Windows/WSL/SSH stay on terminal chat.
* fix(native-chat): restore runtime refusals behind the win32 gate
506d375de3 replaced the project-runtime checks with a bare platform test,
so a WSL or repair-required runtime resolution would no longer refuse
structured chat off-win32. Keep the unconditional win32 refusal and
re-run the runtime resolution after it, so the gate does not depend on
the resolver's own platform guard. Tests inject WSL and repair-required
resolutions on darwin/linux and fail against the regressed gate.
* fix structured session journal durability
* fix structured tab active pointer after restart
* fix(native-chat): await optional lease renewal callbacks
* refactor(skills): extract install error messages
* fix(agent-session): harden recovery ownership
* fix(native-chat): retain panes across tab activation
* fix(native-chat): address round-one review findings
* test(native-chat): align integration coverage after main merge
* fix(native-chat): harden round-two reliability
* fix(native-chat): harden round-three reliability
* fix(native-chat): close round-four recovery gaps
* fix(native-chat): separate bounded journal key forms
* fix(native-chat): reset outbox error in render on session switch
The switch effect adjusted error state after the sessionId prop changed,
tripping react-doctor's no-adjust-state-on-prop-change on the changed-code
gate and flashing the old session's banner for a frame. Reset it with the
render-time previous-value guard instead.
* fix(native-chat): invalidate stale outbox settlements
* test(native-chat): restore settled-error session-switch regression
a6e2379bd1 replaced this test with the in-flight settlement race test,
leaving the render-time error reset unpinned: deleting the reset block
still passed the whole native-chat suite. Keep both scenarios pinned;
they are distinct (settled error clears on switch vs stale settlement
invalidated in the commit-to-passive window).
* test(wire): make release checkouts race safe
* test(wire): pin cross-process checkout single-flight and importer specifier contract
* test(wire): harden release checkout lifecycle
* fix(build): drop CR-byte residue from windows-process-tree patch
The two trailing CR bytes on the patch's deletion lines are a proven
no-op: pnpm hashes patches CRLF-normalized (both forms hash to the
lockfile's 946ffb2b) and materializes this package without applying the
patch in either form, so the load-bearing build edits come solely from
applyWindowsProcessTreeBuildFixes() (#16947), which handles both source
EOL forms. Restore byte-identity with main and repin the contract test
to the post-#16947 reality: LF-only patch bytes plus lockfile hash sync.
* fix(native-chat): skip empty startup recovery
`ssh-pi-compatible-agent-title.spec.ts` kept a private `connectDockerRemote`
that predates #11003. Commit a40183389b gave `fetchWorktrees` a host-qualified
authority gate that short-circuits to `return false` for an SSH host with no
complete `directSshAuthority`, and updated the shared
`connectDockerSshRelayTarget` helper in the same commit -- but never touched
this spec. The fork still does `fetchRepos()` -> bare `fetchWorktrees(repoId)`
-> `worktreesByRepo[repoId][0]`, so the fetch short-circuits, the listing is
empty, `[0]` is undefined, and setup throws
`No remote worktree found for /tmp/orca-docker-relay-perf-repo` before the
spec reaches a single title assertion.
Differential across 5 lane runs (~130 SSH connects): the shared helper's guard
errors fired 0 times; `No remote worktree found` fired 3 times, always via the
fork. The fork accounts for 6 of 7 failures on `e2e / ssh docker watcher
isolation`.
The spec now calls the shared helper. Every helper default matches what the
fork passed (`relayGracePeriodSeconds: 1`, `remotePath`
DOCKER_SSH_RELAY_REMOTE_REPO_PATH, `seedInitialTab` true, port `target.port`);
the helper additionally uses `target.host` rather than a hardcoded
`127.0.0.1`, which is the correct value under `ORCA_E2E_SSH_TARGET_HOST`.
No assertion changed; no retry, sleep, or timeout was added anywhere.
The daemon terminal-host's inspectProcess collapsed every foreground read into
`{ foregroundProcess, hasChildProcesses }`, so a read that observed nothing —
node-pty's POSIX title read silently falling back to the spawned shell, or a
degraded/failed `ps` identity scan letting the cached agent identity go stale —
produced the same payload as a genuinely idle shell. The agent-completion
monitor reads that payload as positive exit evidence and reports a still-running
agent as finished.
The host now reports per-probe live/unverifiable/exited evidence beside
byte-identical legacy fields (docs/reference/ssh-execution-boundary.md), the
same additive `processEvidence` contract the relay and local-provider legs use.
A shell-shaped title is an observation only while a completed scan corroborates
it; a scan that could not answer — unavailable, table missing the pane, or
thrown — settles as unverifiable and withdraws that corroboration.
`SubprocessHandle.observeForegroundProcess` is optional and a handle that omits
it reads as `unverifiable`, so the unknown case falls toward the conservative
arm rather than toward a synthesized observation.
A degraded local foreground read (failed or pane-less ps table, thrown
scan) used to collapse into the same payload as an observed idle shell,
and the agent-completion monitor read it as exit evidence. The local
provider now implements inspectProcess with per-probe
live/unverifiable/exited processEvidence (same contract as the relay
leg), keeps legacy fields byte-identical, and the POSIX resolver stops
claiming availability for cached tables that never contained the pane.
A failed pgrep/ps probe on a relay host resolved as
{foregroundProcess: null|fallback, hasChildProcesses: false}, which the
agent-completion monitor read as positive exit evidence and turned into
a process-exit completion. Probes now return live/unverifiable/exited
verdicts, the relay publishes them in a new optional processEvidence
field beside byte-identical legacy fields, and the monitor refuses to
treat an unverifiable probe as exit evidence.
* style: format codebase
* style: format codebase
* refactor: extract skill install dialog footer and content
Extract footer and content sections from SkillInstallDialog and
SkillInstallManagementDialog into separate components for improved
maintainability and clarity of component responsibilities.
* fix(ssh): a host tab row this client cannot place is unverifiable, not absent
A degraded listLineage leaves worktreesByRepo empty, so exactTargetWorktreeIds
returns nothing, every host path fails to resolve, and importRemoteWorkspaceSession
silently dropped every tab row. The apply then marked the target hydrated and
'synced', freezing that emptiness in permanently — nothing re-pulls a hydrated
target (STA-3593).
The importer now reports unplaceable rows, the apply claims authority only when
every row landed, and a bounded chain re-pulls the missing input (catalog +
lineage). On exhaustion it settles back to the pre-fix behaviour so a genuinely
unplaceable path is never left worse off than today.
* fix(ssh): keep the re-pull chain bounded, unwedgeable, and announced once
Five defects in the first cut of the chain, all found before merge:
1. the caller owned the attempt counter, so an unsolicited host push re-armed
it at 0 and the chain never exhausted - an unbounded workspace.get loop;
2. exhaustion never cleared the counter, so after one bad connection every
later reconnect re-exhausted instantly and the retry was silently dead;
3. the exhaustion check preceded the armed-timer guard, letting a concurrent
report cancel the still-pending final retry;
4. a rejected host read left no timer armed and nothing rescheduled, stranding
the target on 'pulling' and un-hydrated forever - and an un-hydrated target
never uploads again, the exact permanent degradation this design avoids;
5. exhaustion re-announced on every later report, re-marking hydrated and
rewriting status on each host push.
The module now owns the counter, resetTarget gives each connection a fresh
chain, the armed guard precedes exhaustion, the timer body always reschedules
so any failure walks to exhaustion, and exhaustion is announced once.
* fix(ssh): never authorise uploads from a picture known to be incomplete
Reversal of this branch's own exhaustion fallback, on evidence.
Hydration authorises uploads (use-app-session-persistence.ts), and an upload is
a workspace.patch of kind 'replace-session' (remote-workspace-relay-sync.ts:66)
which wholesale replaces the host snapshot (relay/workspace-session-handler.ts).
So marking a target hydrated on a picture we know is missing rows does not
'settle back to the old behaviour' - it uploads an empty projection that DELETES
the host tabs we failed to adopt. Suppressed uploads are recoverable; a wiped
host snapshot is not. That data loss is reachable on main today, because today
the apply marks hydrated immediately.
Exhaustion therefore reports 'error' and leaves the target un-hydrated, so
terminal authority stays 'unverifiable' and no upload can be built from it.
Also closes two chain-lifecycle gaps found in review:
- the callback dropped its timer guard before awaiting the host, leaving a gap
in which a concurrent report armed a second overlapping chain and could trip
exhaustion before the pending apply resolved; an in-flight guard now spans it;
- resetTarget could not cancel a callback already past its await, so a stale one
rescheduled on top of the new connection's chain; chains are now generation
stamped and a stale callback exits.
* fix(ssh): scope re-pull in-flight ownership to a generation
Two races found in review of the previous commit:
- schedule(target,'placed') cleared timer/count/exhaustion but did not
invalidate an apply already in flight. When that apply later resolved
'unplaced' its generation still matched, so it started a fresh chain from
attempt 0; a host repeating placed pushes during each in-flight retry could
reset the budget indefinitely. Retirement now bumps the generation too.
- the in-flight marker was a bare Set, so a superseded callback's finally
deleted whichever marker was present - including one a newer generation had
since taken. A later report could then arm an overlapping timer while that
newer apply was still running. Ownership is now a target -> generation map and
a callback releases only the marker it still owns.
resetTarget deliberately no longer drops the marker: its owner is the only party
that may release it, and clearing it there would let a new chain arm while the
superseded apply is still running.
* fix(ssh): replay an unplaced report that was blocked by a superseded apply
Regression from the previous commit. Keeping the stale in-flight marker across
resetTarget stops overlap, but it also swallows the new connection's result: the
new apply reports 'unplaced', hits the guard because the superseded apply still
owns the marker, and the superseded callback then exits on its stale generation
without scheduling. Nothing replayed the dropped report, so no chain started -
the retry silently never ran for that connection.
A blocked unplaced report is now recorded, and the marker's owner replays it on
release. Only the stale path reaches the finally still owning the marker, so the
normal path - which released early and scheduled its own outcome - cannot replay
twice.
* refactor(ssh): drop the re-pull retry chain, keep the fix
The chain produced ten defects across review - unbounded retry, dead retry,
cancelled final attempt, wedged chain, repeated exhaustion, overlapping chains,
stale-callback cleanup, a lost report - every one in code that passed the full
suite at the time. It bought only faster recovery *within* one connection:
syncAfterConnect and applyUnsolicitedSnapshot already re-pull on the next
connect or host push, so dropping it costs a retry, never the data.
What remains is the part that was correct from the first commit: the importer
reports rows it could not place, and an apply that could not place them neither
marks the target hydrated nor sets 'synced'. Because hydration is what
authorises uploads, and an upload wholesale replaces the host snapshot, that
single rule is what stops a client from deleting the host tabs it failed to
adopt.
Status is now 'error' rather than 'pulling': with no chain pending, 'pulling'
claimed a request that was not in flight.
* test(ssh): name the upload-suppression case for the chainless design
* fix(ssh): revoke stale hydration and keep authority unverifiable when unplaced
Two holes in the previous commit, both found in review.
The hydrated set is add-only (ssh.ts), so withholding hydration only protects a
target that never synced. A target that synced cleanly and then reconnected with
a degraded lineage kept its flag, and hydration is what authorises uploads - so
it would still send a replace-session patch built from the incomplete picture
and delete the host tabs it had just failed to place. Hydration is now revoked,
not merely withheld.
The status phase was 'error'. workspace-terminal-host-authority.ts treats
'offline'/'error' on an un-hydrated target as its bounded floor and resolves
them to 'none' - which authorises seeding AND sleeping-agent resume, the exact
double-resume this gate exists to prevent. 'conflict' is the phase that actually
describes the situation, is excluded from uploads by use-app-session-persistence,
and is deliberately outside that floor set, so authority stays 'unverifiable'.
Both invariants are pinned by tests verified to fail when either fix is reverted
individually; the pre-existing tests passed with both reverted.
* fix(ssh): drop the mismatched message on the unplaced conflict status
The phase drives the user-visible label ('Workspace sync conflict'); carrying an
'unavailable' message alongside it only risked contradicting that wherever the
message is surfaced.
* docs+refactor(ssh): correct the authority floor's premise, drop a dead wrapper
Two findings from the post-merge correctness sweep.
The bounded floor in workspace-terminal-host-authority.ts justified itself on
'remoteWorkspaceHydratedTargetIds is add-only, clearRemoteWorkspaceHydrated has
no production caller'. This branch adds that caller, so the premise is now false
and a future reader would have been misled by it. The comment records the real
consequence: a target that later lands on offline/error reaches the floor having
demonstrably answered, so seeding is authorised over live host terminals. Not a
regression - before revocation existed the same target was marked hydrated and
synced, reaching 'none' sooner - but the floor should learn to tell a revoked
target from one that never answered. Flagged for the SSH-v3 consolidation, where
one authoritative liveness source replaces this pair.
applyUnsolicitedSnapshot had become a pass-through to applyPreparedSnapshot,
carrying a docstring about a re-pull chain that no longer exists. The two
collapse back into one function.
* refactor(ssh): delete the DirectSshSnapshotPlacement union
Consolidation pass finding. The union was exported and threaded through two
modules, but no production consumer ever read it: remote-workspace-ipc-bridge.ts
discards the promise's value and syncAfterConnect ignored it. 'not-applied' was
not a placement at all, only 'this apply did not happen'.
That is a parallel verdict concept with no consumer - precisely what the SSH-v3
consolidation would have had to unpick. It collapses to a local
hasUnplacedTerminalTabs boolean and a void return.
The one test that asserted the return value now asserts adoption instead, which
is the observable outcome rather than a proxy for it. All five unplaced oracles
still fail when the placement decision is forced, verified individually.
* docs(ssh): compress the tombstone rationale to its load-bearing WHY
Elegance pass. Kept the two non-obvious claims - absence cannot distinguish
'never told' from 'user closed', and uuid tab ids make a tombstoned id safe to
drop - and cut the incident narrative around them. The twice-reverted history in
remote-workspace-session-merge.ts is deliberately left alone: that one is
institutional memory about regressions, not restatement of the code.
* test(ssh): pin the fixed behaviour instead of the defect it replaced
The spec was a characterization test whose own title said 'because hydration is
marked even when adoption wrote nothing', and whose comment described exactly
the defect this branch fixes: markRemoteWorkspaceHydrated ran unconditionally
after the hydrate calls, so in the same tick adoption yielded zero, authority
flipped unverifiable -> none, and Terminal.tsx seeded a phantom tab. It polled
for hydrated === true, so the fix turned it red.
It now asserts hydrated === false, phase === 'conflict', and zero tabs - the
count measured before asserting rather than assumed, confirming the phantom seed
is gone. The phase is re-read after the tabs settle and asserted a second time,
because a conflict verdict a later apply flipped back would silently re-authorise
seeding and a single poll would miss it.
The fixme stays a fixme: this branch stops the client claiming false authority
and overwriting the host, but adoption is still the open gap. Declining to seed
is a safe wait, not the destination.
Three-legged A/B against fork point c72afda498, spec byte-identical across the
first two legs:
branch + updated spec -> PASS
baseline + updated spec -> FAIL 'never reported the unplaced snapshot as a conflict'
baseline + original spec -> PASS (baseline actively exhibits the old behaviour)
* 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.