Commit Graph
163 Commits
Author SHA1 Message Date
Neil 39330c5aca fix(relay): retire PTYs the host proves are gone, and stop two per-poll scan storms (#17832)
* fix(relay): stop three CPU growth terms in a long-running remote session

pty.resize gated only on `managed.disposed`, which is bookkeeping rather than
liveness. A shell that exits without node-pty's `onExit` leaves an undisposed
entry holding a closed master fd, and UnixTerminal.resize has no fd guard, so
the ioctl threw `ioctl(2) failed, EBADF` into the dispatcher's generic
parse-error catch. Nothing retired the entry, so it stayed advertised and kept
activePtyCount above zero -- which is what stops a relay with an unlimited
grace from reaching its idle-no-ptys exit (#12423). Probe liveness with the
same helper attach/listProcesses use, retire a provably dead pid, and contain
an ioctl failure over a live-or-unverifiable process.

processHasChildren forked `pgrep -P` per pane per inspection poll, uncached.
procps-ng opens six procfs files per process to resolve one ppid, so each call
cost O(host process count). Answer from the TTL-cached `ps` table the same RPC
already captured for the foreground lookup (#13537).

The remote AI Vault scanner had no parse cache at all, so every forced rescan
re-read and re-parsed the whole transcript corpus, including files untouched
for a month. Give it the mtime+size keyed memo the local scanner has (#13753).

* fix(pty): invalidate the descriptor when node-pty gives up the handle (#17930)

Carried forward from PR #17930, which merged into this branch. Rebased onto
current main; main's newer node-pty-fd-leak test is kept as-is.

* fix(ai-vault): refresh codex titles on the remote parse-cache reuse path

The remote cache keys on the transcript's (mtime, size, host), but codex
titles live in $CODEX_HOME/session_index.jsonl and are written after the
rollout — so a cache hit froze the fallback title forever. Mirrors the
local scanner's existing reuse-path refresh via a shared core.

* fix(relay): publish the exit a reap performs, and rescan for close decisions

Two review findings on the CPU work.

reapExitedPty told only the relay-internal exit listener, so a retirement left
the client's pane mounted against a session the relay had already forgotten --
the next attach answered `PTY "<id>" not found` with nothing before it to
explain why. Pre-existing on three probe paths; resize made it user-triggered.
Publish the same pending-exit the natural onExit path publishes, carrying -1
("gone, status unrecoverable"), and skip it when onExit already reported the
real code.

processHasChildren now answers from a 500ms TTL-cached table. That is right for
pty.inspectProcess, which every tracked pane polls, but pty.hasChildProcesses
gates the window-close confirmation and workspace cleanup's idle evidence --
one destructive decision per answer, where a child started inside the window
would be killed unasked. Give that RPC a fresh scan; pgrep used to.

* fix(relay): publish a reap's exit only on proven-exited evidence

The publication is a verdict the client acts on by retiring the pane, so it
must not be reachable from the disposed-record sweep, which retires off our own
bookkeeping rather than the host's process table. Only ESRCH earns it.

* fix(i18n): restore the activity-options key the rebase dropped

* fix(i18n): union en.json with main so the rebase cannot drop keys
2026-09-02 15:14:44 -07:00
Neil 8197268956 fix(pty,remote): close the pty master fd leak, and two remote-terminal defects (#17914)
* fix(pty,remote): close the pty master fd leak and two remote-terminal defects

so on Linux every later child of the process -- both later pty children and
plain child_process spawns -- inherits it and keeps the /dev/pts device alive.
Measured on Linux with stock node-pty 1.1.0: master fd flags 0404002
(cloexec=false), and 17 -> /dev/pts/ptmx present in both a later pty child's
/proc/self/fd and a later child_process child's. Extend the existing node-pty
patch with pty_cloexec() on both PtyFork spawn paths; after the patch the flags
read 02404002 (cloexec=true) and neither child sees the master. This covers the
app and terminal daemon only -- the SSH relay installs node-pty from npm on the
remote host, so it stays exposed (see the report).

rejecting inspection as a renderer-global unhandledrejection, which an
unreachable runtime produced on every cadence tick.

path cleared the close intent for it exactly like a dropped connection, so a
host that keeps republishing the dead surface re-materialized the pane the user
just closed. Keep that intent and drop its TTL. Also route the banner's
"Remote terminal was closed." line through translate() so it stops mixing
English into a localized banner.

* test(pty,remote): make the fd-leak evidence positive and size the close intent to its RPC

The Linux 'does not hand an earlier pty master to a later pty child' case only asserted that ptmx was absent from the captured listing, so any run that produced no listing passed without inspecting a single fd. Block the child on stdin, emit a sentinel, and assert both the sentinel and a real /dev/pts fd row before the negative assertion. Verified in node:24-bookworm: passes with the patch, and with pty_cloexec() reverted it fails on four inherited /dev/pts/ptmx rows.

The close intent's TTL was a 10s literal while the close RPC that can still answer tab_not_found had its own 15s literal. A host that answered slowly while republishing the surface had its intent evicted by the republish path's own pending-check, so makeWebSessionCloseIntentDurable found nothing to flip and #9194 reproduced. Derive the TTL from the shared session.tabs RPC timeout so the two cannot cross, with an invariant test and a regression test for the slow answer.
2026-09-02 02:23:45 -07:00
NeilandBrennan Benson fbe94ceff6 fix: close readiness gaps found by merged-change audit (#17159)
* fix(ssh): fence stale kills and retired pane replay

* fix(ssh): support cancellable interactive authentication

* fix(ssh): await remote catalog before snapshot adoption

* fix(pty): contain Windows ConPTY input failures

* fix(power): avoid redundant macOS display blocking

* perf(editor): narrow markdown override subscriptions

* fix(quick-open): close directory handles after reads

* refactor(linux): remove unused proc socket scanner

* fix(usage): apply flat Sonnet 4.6 pricing

* ci: prime Node next native test cache

* docs(skills): resolve snapshot cleanup data path

* fix(ssh): recover install locks after host reboot

* test(ssh): recognize boot-aware install locks

* test(ssh): prove previous-boot lock recovery live

* test(wire): pin pre-metadata release coverage

* fix(terminal): preserve remote tab ownership through recovery races

* test(runtime): fence replaced terminal handles in agent guard

* fix(ssh): preserve remote snapshot authority across polls

* fix(pty): contain late ConPTY output EPIPE

* test(pty): register Windows exit watcher before kill

* fix: close SSH and tab readiness race gaps

* fix(tabs): retain headless order and placeholder titles

* fix(build): avoid parallel electron-vite config race

* test(windows): avoid MSYS temp path rewriting

* test(windows): avoid killing exited PTY

* fix(pty): avoid late ConPTY input teardown race

* fix(terminal): sync reconnect error ownership after commit

* fix(runtime): use canonical worktree identity comparison

* test(ssh): assert complete cold-hydration baseline

* test(windows): invoke quoted retention fixture via PowerShell

* test(windows): read ConPTY grid through mode con

* fix(terminal): publish PTY replacements atomically

* fix(terminal): infer stale identity on reattach

* fix(terminal): fence stale pane PTY callbacks

* fix(terminal): fence stale pane binds after rebind

* fix(terminal): reject stale pane transport callbacks

* fix(terminal): fence mirrored reattach spawn callbacks

* fix(terminal): replace stale pane PTYs on remount

* fix(ci): size the Windows launcher-compile test budget from measurement

`native-smoke (windows-latest)` fails ~4.5% of runs on
`preserves a multiline argument through the compiled remote launcher`
with "Test timed out in 15000ms" — on unrelated PRs, for reasons that
have nothing to do with them. Across 176 sampled attempts it is the only
red that job produced, and it hit seven different PRs in two days:
#16900, #16904, #16915, #16955 (twice), #16979, #17014, #17085.

The test is six process creations: powershell.exe forks csc.exe, then
the freshly compiled orca.exe forks node.exe, twice. Hosted Windows
runners periodically slow process creation down, and this test amplifies
that far harder than anything else in the job. Comparing the 80 attempts
where it ran under 3s against the 12 where it ran over 12s, its own
median goes 2198ms -> 15917ms (7.2x) while the same file's
powershell-only test moves 556 -> 686ms (1.2x), the cmd.exe and Git Bash
process tests in the neighbouring file move 1.4x, and the other 35 files
put together move 1.5x.

Measured across those 176 attempts: 1881ms to 35438ms, p50 4264ms,
correlation +0.881 with the job's total Vitest duration. 8 of 176 (4.5%)
exceeded the 15s cap; 2 of 176 (1.1%) also exceeded the shared 30s
testTimeout, so deleting the override and inheriting the config is not
enough on its own. 60s clears all 176 with 1.7x headroom on the worst.

This is slow, not hung. Every body here is synchronous spawnSync, so
Vitest cannot interrupt one — the timer fires only after the body
returns and the reported duration is real elapsed time. That is why a
failure reads `× ... 22464ms` under `Test timed out in 15000ms`. The
work finished; the stopwatch was short. Seven reruns at one identical
head measured 2053 / 4680 / 5551 / 8732 / 13506 / 14868 / 21937ms — the
last of those would have been red on code that had not changed.

The 15s came from #8897, which raised this test off Vitest's built-in 5s
default because the job then ran bare `pnpm vitest run`. #8909 landed
3h27m later and pointed the job at config/vitest.config.ts, which is the
real fix for that. The constant stayed behind and has been the binding
budget ever since.

* fix(terminal): fence stale remount reattach ownership

* fix(terminal): reconcile mounted pane identity after replacement

* fix(terminal): fence stale reattach fallback ownership

* fix(terminal): fence deferred SSH reattach ownership

* fix(terminal): fence stale split pane ownership callbacks

* fix(terminal): keep stale spawns from consuming startup

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-31 08:17:40 -07:00
Neil c09810b641 perf(rpc): restore compiled Zod request schemas without override (#17374)
* perf(rpc): compile Zod request schemas lazily

* chore(deps): pin zod 4.5.4 and except it from the release-age gate

4.5.4 is the first release fixing isRecursiveSchema (upstream 84e416f, #6500),
which compile() calls on every schema — on 4.5.0 it fired .default() factories
at compile time. Verified: compile-time factory calls 0 on 4.5.4, 1 on 4.5.0.
2026-08-31 00:47:22 -07:00
Neil e84042572c Upgrade xterm to 6.1.0-beta.303 and generate addon patches
* Upgrade xterm to 6.1.0-beta.303 and generate the addon patches

Takes the current xterm beta line: xterm 287 -> 303, addon-webgl 286 -> 299,
addon-serialize 287 -> 300, headless 302, the remaining addons -> 300, and the
same set on mobile. All four packages stamp upstream commit d3e32b3.

The reasons are upstream #6042/#6043/#6055 (a shared glyph atlas no longer
garbles sibling panes on a page merge, clear, or sampler-budget overflow) and
Note that core 303 is not image-addon-only over 302: it carries the buffer perf
work, including the new BufferLineStringCache.

addon-webgl and addon-serialize move into the patch generator
--------------------------------------------------------------
Both were hand-edited minified bundles, which is what the Known Gaps section of
docs/reference/xterm-patch-regeneration.md described. Both reproduce byte for
byte from the pinned commit, so they are now manifest entries generated from a
source patch like @xterm/xterm already was. Their sourcemaps now move with their
bundles; before this they shipped maps whose offsets did not match the code
beside them.

The webgl patch shrinks from a 1.06 MB hand-edited bundle to a 6.6 KB source
patch, because upstream took the invalidation half Orca had backported. What is
left is only what upstream still lacks: the fragment-shader else branch for a
v_texpage past the sampler budget, the clearTexture guard that no-ops once a
merged page holds index 0, spending the merge retry budget before beginFrame
latches the version it saw, and Orca's font-weight probe.

The serialize source patch is byte-for-byte the same fixes as before; upstream
changed nothing in that addon between 287 and 300.

Generator fixes, each of which failed silently
----------------------------------------------
- `--relative` was appended after the `--` separator in CHECKOUT_DIFF_FLAGS, so
  git read it as a pathspec and kept repo-root-relative paths, dropping every
  source hunk from an addon's patch.
- `git apply` run from a package subdirectory still resolves patch paths from
  the repo root, skips every hunk and exits 0. It now runs from the root with
  `--directory=<packageDir>`, and a source patch that leaves the checkout
  unchanged is a hard failure rather than an empty patch.
- An addon's own `tsgo -p .` has empty files/include and only project
  references, so it emits nothing and the addon webpack then fails on a missing
  ./out/. The root build now runs first.
- versionStampFile is optional; publish.js stamps an addon's package.json, which
  overlayBuildOutput never patches.
- On a version bump the lockfile has no entry under the new key yet, so --write
  reports the gap instead of aborting mid-run. --check still fails on it.

Adding the two addons pushed the generator and the Electron packaging contract
test over max-lines, so the patch-text helpers move to xterm-patch-text.mjs
(pure text: no checkout, no build) and the vendored-xterm assertions move out of
the packaging contract into xterm-webgl-runtime-contract.test.mjs.

Tests
-----
Four tests asserted upstream bugs that are now fixed, not Orca behaviour:

- xterm-user-scrolling-contract pinned headless and core by version string.
  Upstream bumps each package only when its own output changes, so headless 302
  and core 303 are the same source. It now asserts they share a commit.
- Five CSI 3 J assertions expected a reader stranded at the top after an erase.
  Upstream #6081 clears isUserScrolling there, so the erase releases them to the
  bottom instead. Orca's pin still lands them correctly, because its parser
  handler observes the erase before xterm's own handler runs.
- The IME transaction test hard-coded the xterm version; it now reads the
  installed package, since the point is that bundle, map and version agree.
- The Electron runtime contract asserted Orca's old clearModelGeneration. Shared
  atlas invalidation is upstream's now, so it asserts pageLayoutVersion on the
  resolved dependency, plus the Orca-only hunks on the patch.

Verified: 66,008 unit tests, mobile's 3,863, the four WebGL atlas e2e specs, and
`regenerate-xterm-patches.mjs --check` in sync on all three packages.

Left alone deliberately: resetAllTerminalWebglAtlases still fans out globally
even though clearTexture now self-heals siblings, and upstream #6068
(WebglAddon.dispose leaks the GL context) is still open.

* Drop the two unused WebGL atlas fan-out exports

resetAllTerminalWebglAtlases and presentAllTerminalPanesWithoutAtlasClear have
no callers, and had none at cadfc55102 either — the last call site went in
#6949, which routed reveal recovery through
resetAndRefreshAllTerminalWebglAtlases instead. Only a comment in
pane-manager.ts still named the first one; it now points at the live entry
point. scheduleRevealPresent leaves the registry's structural type with them,
though the manager method stays: terminal-visibility-resume.ts calls it
directly.

This is dead-code removal, not a consequence of the xterm bump. The live
recovery path is unchanged.

resetAndRefreshAllTerminalWebglAtlases stays, and so does the reveal-time
escalation in pane-reveal-repaint.ts. Upstream 299 does make a pane-local
clearTexture bump pageLayoutVersion so siblings rebuild on their next frame,
which is the bug the escalation was written for, but I could not demonstrate
that removing it is safe: with the escalation removed,
floating-workspace-shared-glyph-atlas.spec.ts still passed headful, and it also
passed with upstream's mechanism deliberately disabled (pageLayoutVersion
pinned to 0 in the installed bundle, verified present in the built renderer).
A guard that passes with the fix disabled cannot license removing the
workaround, so the escalation stays until that spec can reproduce the garbling.

Verified: pane-manager and terminal-pane suites (4,713 tests), typecheck, the
headful shared-atlas spec, and the three headless WebGL specs.

* Give the shared glyph atlas spec a trigger that can fail

floating-workspace-shared-glyph-atlas.spec.ts guards the corruption where one
terminal wiping the module-global atlas leaves sibling terminals drawing from
stale texture coordinates. Both of its tests drive that through a floating
panel reveal, and Orca's reveal paths escalate to a registry-wide atlas reset
that repaints every pane — so the recovery under test heals the damage before
the assertion runs, and the tests pass whether or not xterm propagates the
invalidation at all.

The new test clears the shared atlas straight through the floating manager with
the panel closed, so nothing else repaints the workspace terminal, then repaints
it with terminal.refresh(). That is the load-bearing detail: _updateModel skips
cells whose content is unchanged, so the refresh reuses vertices baked against
the pages that were just wiped, which is exactly the state the fix has to
recover from.

Verified as a discriminator rather than assumed. Pinning ITextureAtlas's
pageLayoutVersion getter to 0 in the installed bundle, which disables the
per-renderer invalidation upstream added in addon-webgl 0.20.0-beta.299, and
confirming that reached the built renderer:

  fix intact:   siblingClearIntact=true   1 passed
  fix disabled: siblingClearIntact=false  1 failed

The failure renders the workspace terminal completely blank — stale coordinates
into a wiped atlas sample nothing. The two reveal tests pass unchanged in both
configurations, which is the gap this closes.

* Compare shared-atlas screenshots with tolerance instead of byte equality

Byte equality fails on sub-pixel antialiasing noise that leaves every glyph
legible, so the headful spec flaked under xterm 303. Reuse the existing
compareTerminalScreenshots helper: real stale-model corruption blanks the
terminal at ~3% of pixels, twice the helper's 1.5% threshold, so the looser
oracle keeps its teeth. Log the ratio so failures are diagnosable.

* fix(xterm): cancel empty deferred IME compositions

* test(xterm): strengthen runtime patch contracts
2026-08-30 15:14:49 -07:00
Neil 7f822a73e3 fix(terminal): render the IME caret and give the candidate anchor one owner (#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.
2026-08-30 02:23:04 -07:00
Neil 4bc2085271 Revert "perf(rpc): compile Zod request schemas lazily" (#17368) 2026-08-30 01:21:55 -07:00
Neil 7b86833120 perf(rpc): compile Zod request schemas lazily (#17353)
* perf(rpc): compile Zod request schemas lazily

* test: align window reveal assertion
2026-08-30 01:03:54 -07:00
Neil 4bb9dd5b89 chore(deps): bump electron 43.4.1 and other meaningful runtime deps (#17330)
Take the high-value desktop and mobile upgrades that fix crashes, jank,
or security holes. Leave Electron 44, Lucide 1, Reanimated 4.6, Expo
56/57, and xterm betas for later.

Desktop: electron 43.4.1, @tanstack/react-virtual 3.14.10, mermaid
11.17.2, ws 8.21.3, react 19.2.8, pdfjs-dist 6.3.289, vitest 4.1.11,
happy-dom 20.11.8.

Mobile: Expo SDK 55 patch train, react-native 0.83.10 (IME patch
ported), reanimated 4.3.4, webview 13.16.2 (thread-safe decision
manager; restore WebView generic default so TS 6 does not collapse
props to never).

Electron 43.4 dropped marginType from PrintToPDFMargins; CDP print
mapping now supplies the four sides only.
2026-08-29 20:44:43 -07:00
Neil 2dfaa676d8 chore: update oxlint and oxfmt (#17150) 2026-08-29 14:13:35 -07:00
Neil b17f60d744 build: upgrade to pnpm 12 (#17156) 2026-08-29 14:13:26 -07:00
Neil 0bf5361c92 perf(markdown): update code highlighting incrementally (#17147) 2026-08-29 13:52:16 -07:00
Neil eb00123a81 perf(markdown): skip unmatched list tokenizer scans (#17134) 2026-08-29 13:43:27 -07:00
Brennan Benson fd9125ea8c feat(native-chat): Codex structured native chat restructure (#16729)
* 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
2026-08-28 16:45:58 -07:00
Brennan Benson 4bb337741c feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042) (#16868)
* feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042)

Field instrumentation to name the writer behind regular-text-renders-bold:
- metric-weight-change crumbs at the writePaneMetricOptions funnel
  (prev/next/reason; weights never change in normal operation)
- terminal-weight-parity-mismatch audit on every visibility resume
- sentinel weightProbe capture fields: live options vs atlas captured
  config vs renderer-buffer bold census
- Cmd/Ctrl+Shift+click unconditional capture (no divergence gate, no
  recovery) for states the missing-ink detector cannot see
- patched addon-webgl ctx.font readback probe: detects failed font
  assignments that rasterize glyphs at a stale weight

* fix(terminal): treat canvas weight-700-serializes-as-bold as a match in the atlas font probe

Found by live validation: Chromium's ctx.font getter normalizes numeric 700
to the keyword 'bold', which made every legitimate bold rasterization count
as a failed assignment (124 false positives in one session).

* chore: update patch hash for the font-probe normalization fix

* fix(terminal): bound bold glitch diagnostics

* fix(terminal): cover serialized WebGL probe state

* feat(settings): hidden staff toggle to arm terminal render diagnostics

Replaces the reserved hidden-experimental placeholder slot with a real
switch (Shift-click the Experimental sidebar entry to reveal). It arms
and disarms the render-desync capture sentinel live — no localStorage
incantation, no reload — for the bold-glitch investigation. The passive
probes stay always-on; only the capture gestures are gated.

* fix(settings): make render diagnostics disarm exact

* chore(settings): rename hidden group to 'Hidden experimental settings', drop its description

* feat(settings): unlock hidden experimental group via Option-click on the Experimental page title

Replaces the Shift-click-sidebar unlock with the Updates-header idiom:
Option-click the Experimental page title toggles the hidden group.
Removes the now-unused click-modifier plumbing from the settings sidebar.
2026-08-27 23:36:44 -07:00
Jinwoo Hong 8dd7d6060c fix(release): stabilize native builds across CI platforms (#16947) 2026-08-27 21:42:34 -07:00
Neil 0096e47850 fix(windows): keep windows-process-tree gyp paths absolute under pnpm (#16688)
* fix(windows): keep windows-process-tree gyp paths absolute under pnpm

Hourly Windows builds have failed since #16598 at
`build-windows-process-tree-relay-addon`: `require('node-addon-api').targets`
is cwd-relative, so node-gyp evaluates it from the pnpm store realpath and
then loads it from the `node_modules` symlink. That resolves
`node_addon_api.gyp` outside the repo.

Use `require.resolve` for an absolute path, matching the node-pty patch.

* i18n: keep ja skill-filter labels on the catalog's Agent brand

#16682 merged with a failing localization catalog: ja used エージェント
in three new skill-filter strings, and repair-locale-catalog rewrites
those to Agent. Match the rest of ja.json so static analysis can pass.
2026-08-26 16:15:03 -07:00
Neil 2d500278b4 build(windows): refuse unpatched node-pty prebuilds
Merged after clean CI, Windows packaging verification, and readiness review.
2026-08-24 15:48:47 -07:00
Neil 2b1254d681 fix(windows): own PTY process trees with job objects (#15755)
* fix(windows): own PTY process trees with job objects

Teardown used to answer 'is this tree mine, and how do I kill it?' by
scraping the process table, walking parent pids back to Orca, and running
taskkill /T /F only if the walk said yes. Every step is a guess, and the
code said so itself: windows-pty-root-identity.ts:35 already named the
fix -- 'an inherited handle / Job Object'.

The guesses fail in the ways users report. A pid walk cannot survive pid
reuse, so teardown refused whenever it could not prove ownership, and a
refused kill is an orphaned agent tree holding the worktree directory
open (#9045, #10475, #10087). A descendant that reparented is invisible
to the walk. The scrape itself could be blocked by policy, which read as
'no evidence'.

node-pty now creates a job object per ConPTY and assigns the shell under
CREATE_SUSPENDED, before it can spawn anything -- assigning afterwards
leaves a window in which a fast child escapes. Termination is one
TerminateJobObject; liveness is QueryInformationJobObject.

Verified on Windows 11 against a shell whose grandchild was spawned
detached: job membership came back [shell, grandchild] and one call
killed both. Neither a parent-pid walk nor GetConsoleProcessList sees
that grandchild -- it leaves the console and reparents, which is exactly
the claude.exe/node.exe/cmd.exe orphan in #9045.

KILL_ON_JOB_CLOSE means a daemon that dies without unwinding no longer
strands shells (#9195, #10415). The job is the daemon's, not the app's,
so an app-main crash still leaves sessions alive -- the guarantee
win-crash-survival-e2e asserts.

Both entry points report unavailable rather than a false success when a
pty has no job: an outer job without BREAKAWAY_OK can refuse the
assignment, and a pty from an older build has none. Reading 'we could
not tell' as 'already dead' is the original bug, so the old probe stays
as the fallback.

* test(windows): pin job ownership against a real detached grandchild

The unit tests pin the contract; this pins what the contract is for. A
grandchild spawned detached leaves the pane's console and reparents, so
GetConsoleProcessList and a parent-pid walk both miss it -- that is the
process that outlived its pane and held the worktree directory open.

Includes a guard that this build actually has job support, so a node-pty
rebuilt from unpatched sources fails loudly instead of letting every
assertion pass vacuously.

* fix(windows): correct the job liveness contract to what Windows actually does

I claimed an emptied tree would report [] and that this was the evidence
a stale registry entry lacks (#15549). Running it on Windows 11 showed
otherwise: node-pty drops its handle record and closes the job when the
shell exits, so a dead tree reports null.

Null therefore means unverifiable in the sense of
docs/reference/ssh-execution-boundary.md -- no job support, not a ConPTY,
or no longer tracked -- and is never evidence that processes died. A
caller reading it as proof of death would have been right by accident
after a normal exit and wrong on a host that refused the assignment.

What the API does add is descendant liveness for a tree that is still
tracked, including children that detached from the console.

* fix(windows): stop a clean shell exit from reaping backgrounded processes

Measured on Windows 11: with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE on the
per-PTY job, releasing the handle when the shell exits also killed
whatever the user had backgrounded. Typing 'exit' in a pane reaped a
detached server that survived before this patch.

That is a behaviour change nobody asked for. The approved change was
that killing the terminal daemon reaps its shells -- not that a clean
exit reaps your background job. The job's purpose is to make an EXPLICIT
teardown exact, which TerminateJobObject still does.

Reaping a dead daemon's shells now needs the daemon-level job the design
called for: the daemon assigns itself, children inherit membership, and
its closure on daemon death reaps them without touching clean-exit
semantics. Not in this PR; noted in the reference doc.

* test(windows): pin that a clean exit leaves backgrounded work alone

The counterpart to the tree-kill test. Without it, re-adding
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE would look like a tightening rather
than the regression it is.

* fix(windows): stop a winpty pty id from matching a ConPTY job

winpty.cc and conpty.cc each mint their 'pty' id from an independent
counter, and windowsPtyAgent stores both in the same _pty field. So a
winpty-backed terminal's id can collide with a live ConPTY baton -- and
closing that pane would have terminated an unrelated pane's entire
process tree.

Both job entry points now take the shell pid and the native side refuses
unless GetProcessId(hShell) matches, which makes the id unforgeable.

Two more from the same read-through:
- ResumeThread's failure was ignored. A shell left suspended is a pane
  that never prints and never exits, which is far harder to diagnose
  than a failed spawn; it now cleans up and throws.
- handle->hJob was assigned before LoadConptyDll, which can throw. A
  baton carrying a job but never reaching SetupExitCallback has nothing
  left to close it, so the assignment moved down beside hShell.

* docs(windows): record the unsynchronised node-pty baton table

Pre-existing upstream -- the exit thread erases while the main thread
reads -- but terminatePtyJob adds an instance of it, so it belongs in
writing rather than in someone's head.

* fix(windows): close four gaps found in review

BREAKAWAY. The per-PTY job set no limits, so a child asking for
CREATE_BREAKAWAY_FROM_JOB was refused with ERROR_ACCESS_DENIED.
Installers, msiexec and some updater and service-control paths spawn
that way deliberately -- they worked before this patch and would have
failed only inside an Orca terminal, which is the worst shape a bug
report can take. JOB_OBJECT_LIMIT_BREAKAWAY_OK restores it; a child
still has to ask, so ordinary descendants stay owned.

EMPTY IS NOT UNAVAILABLE. The native reader returns an empty list --
not an error -- when CreateToolhelp32Snapshot fails, which is what an
EDR hook or a restricted token produces. Callers read that as 'nothing
is running' and teardown concludes a live PTY root is already gone. The
snapshot must contain the querying process; nothing else is
unfalsifiable, and one predicate catches empty, truncated and
permission-filtered tables alike.

NO DEADLINE. Replacing execFile dropped its 3s timeout. The vendored
reader latches a module-global while a request is in flight and clears
it only after draining its callbacks, with no try/catch -- so one wedge
leaves every later call queued behind a promise that never settles, and
the process table is dead for the life of the app. The bound is back.

GUESSED IMAGE PATH. executablePath was derived from the first
space-delimited token, which reads 'C:\Program' out of an unquoted
'C:\Program Files\nodejs\node.exe ...'. Wrong evidence is worse than
none, and the only consumer already had the full path in , so
the field is gone rather than repaired.

Also: remove_pty_baton no longer sits inside assert(), which NDEBUG
would compile away along with the call, and the job accessors hold a
lock across lookup and use -- handle values are recycled, so an
unguarded read could pass the shell-pid check against an unrelated
process and terminate the wrong job.

* fix(windows): apply the job lock once per accessor

The patch script matched a string its own replacement still contained, so
PtyTerminateJob got two lock_guards named guard and PtyListJobProcessIds
got none. MSVC caught it: error C2374 redefinition.

* test(windows): pin that a child can still break away from the job

Verified on Windows 11: 'start /b' writes its marker and no access-denied
appears. Without JOB_OBJECT_LIMIT_BREAKAWAY_OK this fails, and it fails
only inside an Orca terminal -- so the failure would look like Orca
corrupting unrelated software rather than like a job-object change.

* fix(windows): stop the ownership guard from reading a closing handle

The guard called GetProcessId(hShell) to prove identity, but the exit
watcher closes hShell on another thread -- so the guard could read a
closed handle, and under strict handle checks that is fatal rather than
merely wrong. Worse, it widened the gap between validating hJob and
using it from two instructions to a kernel round-trip, and handle values
recycle: the likeliest occupant of a freshly recycled value in this
process is another pane's job.

The pid never needed a handle. It is captured at spawn and compared as a
DWORD, so the guard touches no handle at all, and hShell is now closed
inside the same lock as hJob.

Also from review:
- reject CR/LF in a cmd argument. cmd ends the command at a raw line
  break whatever the quote state, so there is no escape for it; encoding
  one anyway truncates the argument and can leave the remainder to run
  as a command. Agent prompts are this encoder's motivating input.
- ask the process table only for the fields a caller needs. Memory and
  CommandLine each cost an OpenProcess per process, inline, for every
  process on the box -- and the 1024 bound is patched out. Ancestry
  reads now skip both.
- corpus gains the degenerate quote-only and two-quote arguments.
- PtyListJobProcessIds' docblock still taught the empty-list contract
  that was corrected on the TS side, and now records that the ConPTY
  console host is never a job member.
- drop a write to NumberOfAssignedProcesses, which is output-only.
- pty_baton::hShell is initialised; ownsShell was only safe because &&
  short-circuited ahead of it.

The backgrounded-child test is rescoped: 'start /b' uses
CREATE_NEW_CONSOLE, not CREATE_BREAKAWAY_FROM_JOB, so it proves job
membership does not block backgrounding -- not that BREAKAWAY_OK works.
That flag rests on the Win32 contract, and I have said so rather than
letting the test imply coverage it does not have.

* fix(windows): bound retries after the process table wedges

The 3s deadline stops a caller hanging, but the timed-out call leaves its
callback in the vendored module's queue -- and that queue drains only
when the latched request completes, which in this wedge never happens.
Retrying at the caller's poll rate would add a closure per tick forever.
A 30s cooldown bounds it to one probe, and a late callback clears the
cooldown because it proves the reader recovered.

Also pins the deadlock invariant in the patch: the exit thread's lock
must close before tsfn.BlockingCall, because that waits on the JS thread
and the JS thread can be waiting on the same mutex inside
PtyTerminateJob. Correct today by scoping; a comment so a later refactor
does not widen it.

* revert(windows): drop the field-selection API, which cannot pay off

I added it for a real perf finding -- Memory and CommandLine each cost an
OpenProcess per process -- and then never wired a caller, so the claim
that ancestry reads skip them was wrong.

Wiring it would have been worse than leaving it dead. The only ancestry
consumer is the teardown identity probe, which needs a snapshot that
started AFTER it asked, for pid-recycle detection. Bypassing the shared
reader to get narrow fields would let that request join a scan already in
flight -- trading a correctness guarantee for milliseconds.

Field selection only pays off if callers can ask for less, and they
cannot: one shared snapshot serves every caller so a 32-wide teardown
collapses into a single scan, which means it has to carry every field.
The reasoning now lives next to the flags instead of in a dead export.

* fix(process): three P1s from review — a crash vector and two wedge bugs

STDIN EPIPE COULD TAKE DOWN THE MAIN PROCESS. A child that exits without
reading makes the queued write fail with EPIPE, and an unhandled error on
a stream is an uncaught exception. The child's own error listener does
not cover its stdin stream, so runProcess({ input }) against a
short-lived child was a crash, not a failed call.

THE COOLDOWN LEAKED A BATCH PER CYCLE INSTEAD OF BOUNDING IT. At expiry
every concurrent caller passed the check before any of them re-armed it,
so each enqueued a callback into the still-latched native queue and each
cycle leaked another batch. The cooldown is now re-armed BEFORE probing,
so exactly one caller gets through.

A SYNCHRONOUS THROW LEFT ITS DEADLINE RUNNING. The timer was declared
inside the try, so catch could not clear it; it fired later and wedged a
reader that had already recovered. Hoisted and cleared, and wedge state
now carries a generation so a request that lost its deadline cannot
mutate it on behalf of the one that replaced it.

Found by review once the prompts were short enough for the reviewer to
finish -- the previous two rounds died on prompt length.

* fix(process): stop a stream error from crashing the main process

Same class as the stdin EPIPE finding, two instances further on: stdout
and stderr had data listeners and no error listeners, and an unhandled
error on a stream is an uncaught exception.

Scoped to runProcess, which owns the child outright. spawnProcess hands
the streams to its caller, and a blanket handler there defeats callers
that track and remove their own listeners -- the SSH ProxyCommand
transport does exactly that, and its cleanup test caught the attempt.
Documented on spawnProcess so the boundary is explicit rather than
inferred.

* fix(windows): validate the ConPTY DLL before creating the process

LoadConptyDll throws when conpty.dll is missing -- a real state, and one
this branch hit during development. It ran after CreateProcessW and
ResumeThread but before the baton and the exit watcher were installed,
so a throw leaked the job, process and thread handles and left an
untracked shell tree running. Once per attempt, so a broken install
accumulates orphan shells on every retry.

Resolving the DLL first costs nothing and leaves exactly two throws
after creation: the CreateProcessW failure, where nothing exists yet,
and the resume failure, which already cleans up after itself.

This also closes the same leak for hProcess and hThread, which predates
the job work.

* feat(windows): add the daemon-level job the design called for

The plan specified two nested jobs and I built one. That gap is why
dropping KILL_ON_JOB_CLOSE from the per-PTY job cost the approved
guarantee that a dead daemon reaps its shells -- I had one job trying to
answer two questions, and the two answers conflict.

They are separate jobs. The per-PTY job answers 'kill exactly this
pane's tree, now', and cannot be kill-on-close because its handle is
released when the shell exits, which would reap whatever the user
backgrounded. The daemon assigns itself to a second job that IS
kill-on-close; its handle is released only when the daemon dies.
Children inherit membership, so every pty is covered and the per-PTY
jobs nest inside it.

Daemon, never app: an app-main crash must still leave sessions alive,
which win-crash-survival-e2e asserts. Both jobs carry BREAKAWAY_OK, or a
child asking to break away is refused at whichever level lacks it.

Restores #9195 and #10415, which I withdrew from this PR earlier.

* docs(windows): record what the host job does not cover

An app-hosted PTY gets a per-PTY job but no crash reaping, because the
alternative is a kill-on-close job on the app -- which is precisely what
the crash-survival guarantee forbids.

* ci(windows): run the win32 suites in the PR windows job

Both were skip-on-non-win32 and had only ever run on one machine I drive
by hand -- which went unreachable at exactly the moment I needed to
verify the percent-escaping fix. Verification that depends on one box is
not verification.

The job already builds node-pty from patched source and already runs a
useConptyDll test, so the ConPTY runtime files are in place by this
step. This also makes the encoder a gate: the corpus is the only thing
standing between an agent prompt and a mangled argv, and it now runs
against real cmd.exe on every PR.

* fix(deps): refresh the lockfile for the current patch hashes

pnpm records a hash per patched dependency, and I regenerated both
patches repeatedly across the review rounds without refreshing the
lockfile. Every local run used --frozen-lockfile's looser sibling, so
nothing caught it until CI did:

  ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen
  installation. The current "patchedDependencies" configuration doesn't
  match the value found in the lockfile

Verified with pnpm install --frozen-lockfile locally this time.

* ci(windows): build node-pty from source before the win32 suites

CI proved the encoder fix on real cmd.exe -- 26/26 -- and in the same run
proved the job suite had been testing an unpatched binary. node-pty
prefers its upstream prebuild, which does not contain this patch, so
every job-object export was absent and isPtyJobOwnershipAvailable() was
false.

That guard is why the failure was loud rather than a vacuous pass, and
it is the reason the assertion exists.

Packaging was never affected: rebuild-native-deps.mjs already builds
node-pty from source for Electron and restores the ConPTY runtime files.
The gap was the node-runtime test environment only.

Not changing requiresPatchedNodePtySourceBuild's win32 exemption here.
Its premise -- that the patch is Unix-only -- is now false, but lifting
it also needs pnpm rebuild to force a source build, and I cannot
validate that on macOS and Linux from here. Recorded as a follow-up
instead of changed blind.

* test(windows): gate the host-job guarantee in CI

The daemon-level job had one hand-run proof and no automated coverage --
the same shape of gap that let an unpatched node-pty go unnoticed until
CI caught it.

It needs a real second process, because the assertion is about what
happens when that process is force-killed: a host in a kill-on-close job
must strand neither its pty nor a grandchild spawned detached, which is
the process a parent-pid walk cannot see.

Runs in the Windows PR job alongside the per-pty and encoder suites, so
both halves of the two-job design are now gated rather than asserted.

* fix(windows): serialise host-job creation

Two callers racing PtyAssignCurrentProcessToJob would each create a job,
put the process in both, and leak the first handle -- and the handle is
what keeps a kill-on-close job alive, so a leaked one is never released.
'Only JS calls it' is not a guarantee: a worker thread with its own
N-API env shares these statics.

Also records the ordering requirement it depends on.
AssignProcessToJobObject adds only the named process; children inherit
membership, but a pty that already exists does not join retroactively
and would not be reaped. The daemon assigns at startup, before the
ConPTY warmup and before any session, which is correct today and now
stated rather than implied.

* fix(daemon): keep the host job off the startup path

Assigning the host job at daemon startup resolves the node-pty native
module, which loads the ConPTY addon -- and paying that before the
endpoint is published delayed readiness enough that daemon-boot-smoke
failed on windows-latest, deterministically.

windows-conpty-warmup already carries the comment for this exact
hazard ('setImmediate keeps the ready/handshake path ahead of the
warm-up') and I put an eager load in front of it anyway.

Moved to the pty spawn path, which already pays ConPTY cost, and
memoised. Children inherit job membership, so assigning immediately
before the first spawn still covers every pty -- and nothing can spawn
one before the endpoint exists.
2026-08-21 22:31:36 -07:00
Neil 057fbfcffc perf(windows): read the process table natively instead of forking PowerShell (#15749)
* perf(windows): read the process table natively instead of forking PowerShell

Seven independent readers each forked powershell.exe to run
Get-CimInstance Win32_Process, with a wmic fallback that Windows 11 24H2
has removed. On a domain-joined host with PowerShell Transcription
enabled by policy, one of them running every ~2s recorded ~289GB across
1.4 million files (#15209). The same scan cost ~700ms and ran per pane
(#15036), and a Group Policy or AV block turned it into 'unavailable',
which callers read as 'no evidence' -- which is how a PTY tree survives
its own teardown (#9045, #10475).

A Toolhelp32 snapshot answers the same question with no child process.
Measured on Windows 11 with 1050 processes, p50/p95:

  pid+ppid+name          15.9 / 17.5 ms
  +memory +command line  30.6 / 33.7 ms
  Get-CimInstance         706 / 723  ms

Two upstream defects needed patching, both found by running it on real
hardware. The binding requires Spectre-mitigated libraries our agents do
not carry (node-pty is patched the same way). And enumeration stopped
after 1024 processes: on a host with 1051 the module returned exactly
1024, and the querying process was itself among the 27 missing -- a
truncated snapshot silently hides the descendants teardown is looking
for, which is the failure this whole change exists to remove.

Migrated: the foreground/descendant reader (the #15209 scraper and the
teardown identity gate) and the port scanner's PID attribution. NOT
migrated: the memory collector and three identity probes, which need
Win32_Process.CreationDate and have no native equivalent. Start time is
a proxy for identity anyway; an inherited job handle is the real answer,
so those belong with the job-object work rather than here.

Packaging follows the windows-native-registry contract exactly:
optional, absent from onlyBuiltDependencies so macOS/Linux never run
node-gyp, win32-only in the packaged runtime. Asserted by the existing
contract test, which also stops pinning a whole source literal that only
tested its own formatting.

* chore(process): ratchet the child_process allowlist down

windows-foreground-process-rows.ts no longer spawns anything, so its
allowlist line is stale. The guard fails on a stale entry as well as a
new one, precisely so a migrated file cannot keep a slot open and hide
the next regression in the same path.

* fix(ports): import the process-table reader the scanner uses

Missing import: the migration replaced the PowerShell call but the new
symbol was never imported, so tsc failed. Vitest transpiles without
typechecking, which is why the port-scanner suite stayed green.

* fix(deps): sync this branch's lockfile with its patch set

Same class as the fix on the tip branch: pnpm records a hash per patched
dependency, and this branch introduces the windows-process-tree patch
without its lockfile entry matching. Every job here failed at install
with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Verified with --frozen-lockfile, which is what CI runs and what my local
runs were not.

* test(relay): drive the relay's Windows fixtures from the native snapshot

Two relay cases fed a PowerShell CIM payload through a mocked execFile.
That reader is gone, so both failed -- deterministically, on every PR
run for this branch and the one above it.

I did not catch it because my own verification sweep was
'src/main src/shared config/scripts' and never included src/relay. The
relay is a first-class consumer of the process table; leaving it out of
the sweep is how a deterministic failure survived six review rounds.
2026-08-21 21:54:57 -07:00
Neil fdd4091ebd fix(hooks): isolate lint-staged backups per worktree (#15388) 2026-08-19 22:37:02 -07:00
Neil d143922561 fix(terminal): deliver an IME commit the deferred textarea diff missed (#15198)
Picking a single Chinese character from the candidate window with a
number key loses it. The character flashes and disappears. Picking the
same candidate with the mouse works, and picking multi-character words
with number keys works.

Two paths can deliver an IME commit, and this falls between them. A
keydown the input method consumed routes into a setTimeout(0) diff of
the helper textarea, and that diff is what normally delivers the commit;
xterm's _keyDownSeen guard exists to defer to it. When the commit
arrives after that timer has already run, neither path delivers. Mouse
selection works because no key is down, and a real composition session
works because it takes a different path entirely. That narrows it to an
input method whose commit round-trips asynchronously and which shows no
in-application preedit.

Track that a consumed keydown still owes its commit, and deliver only
when the diff did not. The upstream guard and its single read site are
untouched, which is what keeps the duplicate-commit behaviour it was
added for sealed.

Not doing the obvious repairs deliberately: clearing the flag, skipping
it for keyCode 229, or setting it after the composition short-circuit
each unblock the input path without retiring the diff, and all three
were measured emitting the character twice.

The patch and the lockfile hash here are generated. Review
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch, which
is the hand-written source of the change; the shipped patch and both
minified bundles are the regenerator's output from the pinned upstream
build, so nothing in this change was hand-transcribed into a bundle.

Refs xtermjs/xterm.js#6036
Closes #12099
2026-08-17 22:13:32 -07:00
Neil 49752477a6 build(xterm): restore the patch regeneration harness and gate it in CI (#15223)
* build(xterm): restore the patch regeneration harness and gate it in CI

docs/reference/ime-architecture.md says "Never hand-edit the bundles in
the patch" and links to docs/reference/xterm-patch-regeneration.md. That
doc does not exist, and neither does the harness it describes.

Both landed in 29117bf776 and were deleted by 17cfc968cf, a revert of
the composition-ownership change, which swept up a build tool and a CI
gate as collateral. The rule survived; its enforcement did not. Every
xterm patch since has had to hand-edit minified bundles to comply with
the surrounding architecture, because everything resolves to
lib/xterm.mjs at runtime and under vitest, so a src-only edit is inert.

The shipped bundles were therefore not the output of any build, and this
restores them to build output. Comparing identifier multisets against a
pristine build of the pinned commit finds hand-written names a minifier
never emits ($rl, $hp, $tid), const in an otherwise let-only esbuild
bundle, !! where the source reads Boolean(), an escaped LRM where esbuild
emits the literal, and a return block esbuild collapses to void(...).
Every remaining token difference is a minifier local reallocating.

The old source patch could not be reused. It described the reverted
composition-ownership architecture, so restoring it would have re-applied
an abandoned design on top of dropping three accumulated fixes. It is
re-derived from the shipped patch instead, and the derivation is a fixed
point.

Two deliberate departures from the deleted version. Sourcemaps are
included rather than deleted, because a live test reads lib/*.map and
asserts the mapped version matches the runtime version. The source-patch
superset carve-out is gone, so a source hunk the shipped patch cannot
name now fails loudly instead of being carved out silently.

The doc's claim that the webgl and serialize addons reproduce byte for
byte was half wrong. Their ESM output does reproduce at the pinned
commit, but both also publish CJS that the root package script never
builds, so folding either in needs a build step this harness lacks.
Recorded as a blocker rather than a confident sentence.

xterm_patch_sync runs the regenerator in --check mode, so a patch that
does not match a rebuild of the pinned upstream now fails PR CI.

The -diff -text attribute is required, not cosmetic: pnpm hashes the
patch byte-for-byte, so a CRLF checkout breaks the install outright.

Not verified: the CI job has not run on a real runner, the addon CJS
bundles are unreproduced, and the generator is untested on Windows and
Linux.

* build(xterm): make the regenerator runnable on Windows and drop dead paths

Readiness review on the restore found one blocking gap and two cheap
cleanups. None of them change the emitted patch, which is byte-identical
before and after.

The generator could not run on Windows at all. Three sites called npm
through execFileSync with shell:false, but npm ships as npm.cmd there,
execFile applies no PATHEXT, and since CVE-2024-27980 it refuses a .cmd
target without a shell. That matters because this harness arms a
blocking gate whose documented remedy is --write, so a Windows
contributor who tripped the gate had no remedy except hand-editing a 7MB
minified bundle, which is the practice the gate exists to abolish. Four
sibling scripts in config/scripts already handle this; the fix follows
them and lands in run(), so the manifest-driven build step is covered
too. git and tar are real executables in System32 and keep resolving
without a shell, which avoids quoting exposure on paths with spaces.

deleteGeneratedSourcemaps was unreachable, since the policy is include.
Deleting it left "delete" as a legal policy value that nothing honoured,
so a manifest asking for it would have silently shipped sourcemaps that
do not match the bundle. The enum is narrowed and an unrecognised policy
now throws rather than falling through.

generatedHunks moved into the test file rather than being dropped; its
partition assertion, that generated and source hunks reconstruct the
whole patch, is worth keeping.

The -text attribute now covers all five patch files. pnpm hashes each of
them byte-for-byte, so the CRLF hazard the xterm patch was protected
from applies equally to node-pty and the three addons. All five were
already LF in the object DB, so this pins existing behaviour. -diff
stays scoped to the xterm patch, since the others are readable.

The doc's claim that the addons reproduce byte for byte is now dated and
marked a one-off measurement rather than an invariant, because nothing
re-runs it.

Effective lines fall from 591 to 568 against the 600 budget. Still the
largest file in config/scripts, and adding a second package to the
manifest would need a split first.
2026-08-17 21:31:20 -07:00
Neilandrayim 453237cc57 fix(terminal): render the row tail the IME preedit overlay covers (#15014)
* fix(terminal): render the covered row tail inside the IME preedit overlay

Closes #12545.

Composing mid-line hid the character at the cursor for the whole composition.
The preedit overlay is an opaque box anchored to the cursor cell, and nothing
reaches the pty while composing, so those cells still held their characters —
the box simply covered them.

`CompositionHelper` now draws the rest of the row after the preedit inside the
view, so the composition reads as inserted text pushing the tail right. Four
details come with it:

- The view is start-anchored while it carries a tail, so the preedit stays put
  and the pushed tail clips at the right edge; alone, `rtl` still keeps a long
  preedit's end in view.
- It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any
  alpha dropped — the view masks the cells it draws over, so a see-through
  background would re-expose the very characters the tail stands in for.
- The helper textarea syncs to the preedit's own bounds, so IME candidate
  dialogs anchor to the composing text rather than past the rendered tail.
- A TUI can repaint the row under an open composition, so
  `updateCompositionElements` — which already runs on every render — re-reads
  the remainder and re-renders on change. A string compare adds no layout read.

The tail is read with an explicit end column: the cacheable form of
`translateToString` arms the line string cache's self-renewing idle-clear timer,
and the composition path must own no timers.

Geometry is not the cause. Two mature reference terminal implementations compose
marked text into the grid rather than into a floating box, and both still blank
the cells under it — one of them literally substitutes the marked characters
into the row's character array before rasterizing. Moving off the overlay would
not have fixed this report; rendering the covered tail is what does.

The e2e arm asserts the invariant an opaque overlay owes the grid: it must
render every committed cell its bounding rect covers. That is measured from the
real rect against the real cell grid, so it fails on the unfixed build with
`covers "하" / renders "가"`.

Known limitation: the rendered tail is plain-styled while composing (theme
foreground on theme background, no per-cell colors); colors return on commit.
This is inherent to the overlay, and drawing the preedit into the cell renderer
instead would be a far larger change.

Co-authored-by: rayim <rayim@fxy.global>

* test(e2e): assert the occlusion invariant, not the runner's cell width

CI covered four columns where this machine covers two — 34.4px over an 8.43px
grid against 12.3px over an 8px grid — so pinning the covered text verbatim
pinned the font metrics rather than the behaviour. Assert instead that every
committed cell the overlay covers appears in what it draws, which is the actual
invariant and holds at any cell width.

Still fails against main: covers "하" / renders "가".

* fix(terminal): keep the rendered tail's spacing on the grid

The composition view is white-space: nowrap, which collapses runs of spaces
exactly like normal — it only suppresses wrapping. So a committed tail carrying
padding drew its trailing glyph cells left of where the grid has them: measured
in Chromium with xterm's own rule, twenty spaces plus a border rendered two
cells wide instead of twenty-one.

The visible case is Orca's most common IME context — composing inside an agent
TUI input box, where the row is a prompt, padding, then a real border glyph the
trim cannot drop. A stray border appeared a cell after the preedit while the
real one stayed put.

xterm sets white-space: pre on its grid rows for this reason; the view was only
nowrap-safe while it held preedit text alone.

The existing fixtures are all space-free, and the e2e invariant is that the
overlay renders everything it covers — collapsing makes it cover less, so both
stayed green. Pinned with a padded-row fixture.

---------

Co-authored-by: rayim <rayim@fxy.global>
2026-08-17 00:18:11 -07:00
Neil 17ef6ccce6 fix(terminal): clear the preedit overlay when an IME cancels a composition (#14758)
Backspacing over the last radical of a Cangjie composition empties the IME's
marked text without reaching compositionend, and the vendored xterm
CompositionHelper only dropped the overlay's `active` class there. The box
stayed painted with whatever glyph it last held (#11951).

Clear on the state rather than on the key, as native terminals do: an empty
`compositionupdate` now hides the overlay instead of only ever showing it, and
a key the IME swallows re-derives the preedit from the textarea once it settles
so a composition emptied with no composition event at all is cancelled too.
2026-08-16 15:53:16 -07:00
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
Brennan Benson 774bbc788f fix(terminal): preserve OSC 8 links across cold parking (#13382)
* fix(terminal): preserve OSC 8 links across cold parking

* test(terminal): make OSC 8 e2e cross-platform

* test(terminal): focus OSC restore e2e on activation
2026-08-09 16:36:12 -07:00
NeilandOrca a47b9d1167 Show a preedit the IME resumes without a compositionstart (#13284)
* fix(terminal): show a preedit the IME resumes without a compositionstart

Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so
the user composes each syllable blind. Long-standing hole in the vendored
terminal library, not a regression: the same test fails identically against the
bundle this branch starts from.

The `.active` class that CSS keys `display: block` off is added only in
`compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on
Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no
second `compositionstart`, by which point `compositionend` has already hidden the
overlay, so the resumed preedit is written into a hidden element and never
positioned. `updateCompositionElements` also early-returned on `!_isComposing`,
so it would not lay the overlay out either.

Re-show the overlay on an update that carries data, and key the layout guard on
the shown overlay instead. `_isComposing` is deliberately left alone, so no
commit bookkeeping changes and `onData` stays byte-identical. The two guards are
equivalent on every pre-existing path: `compositionstart` sets both,
`_finalizeComposition` clears both.

The bundle hunks are the same two edits applied to the shipped minified output;
the sourcemaps are carried through unchanged.

* test(terminal): prove the resumed-preedit fix against a recorded Windows capture

The synthetic test pins the shape; this replays events a real Microsoft Korean
IME emitted on Windows/WSL. The capture holds three compositionupdates that
resume a composition with no second compositionstart — the exact ordering that
wrote the preedit into a hidden overlay.

Without the fix all three report shown:false; with it all three are visible.
Fixture derived from the sealed 11919-windows-wsl-current capture, which is
read-only and unmodified.

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

* test(terminal): stop the recorded Hangul fixture pinning a derivation artifact

The capture logs each event twice — a dispatch record and a batched next-frame
re-log. Deriving from both replayed every event twice, which made three
compositionupdates appear to land after a session had ended. Filtered to
dispatch records the capture holds zero resumes and 11 balanced sessions, so
the previous toHaveLength(3) was pinning an artifact of the derivation.

Re-scoped to what the capture does prove: the preedit stays visible across all
37 real updates. Verified by reverting the patch that this passes either way,
so it is coverage and the synthetic test remains the discriminator. Both facts
are now stated in the file.

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

* fix(terminal): restore the preedit visibility patch onto its own branch

The previous commit accidentally reverted it: checking main's patch and lockfile
into the worktree to test whether a test discriminates also stages them, so the
commit that followed swept them up.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-09 15:30:26 -07:00
NeilandOrca 17cfc968cf Revert the terminal IME composition-ownership change (#13282)
* Revert "test(ime): restore coverage the composition-ownership change removed (#13168)"

This reverts commit 25a8c517e1.

* Revert "refactor(terminal): return IME composition ownership to xterm (#13128)"

This reverts commit 17b3dff3c4.

* test(ime): keep the architecture-neutral Korean trace coverage

The recorded IBus/fcitx5 and Windows MS-Korean traces from #13168 assert PTY
byte order, not composition ownership, so they still hold once the terminal
composition layer is restored. The mobile accessory-order test pinned the new
handleLiveInputChange signature and does not.

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

* fix(terminal): keep the macOS Backslash bypass through the revert

The restored native-text forwarder only claims keys for input sources in its
hardcoded CJK allowlist, so third-party IMEs off that list (Qingg, #10896) still
get a raw backslash. #13128 added this bypass as a partial replacement; keep it
rather than trade the open issue back.

Scoped to the bare backslash key. The rest of shouldBypassXtermForMacNativeText
bypassed all unmodified non-ASCII text, which would race the restored forwarder.

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

* fix(mobile): move the mirror-step ref write out of render

The restored hook assigned runMirrorStepRef during render, which is not
replay-safe — React can discard render work, so the mutation can leak from UI
that never commits. Its only read is inside the held-commit timer, which fires
long after commit, and the ref has a safe default, so an effect is soon enough.

Surfaced by the changed-lines React Doctor gate: the rule postdates this code,
so restoring the file re-introduced it as a new violation.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-08 19:06:50 -07:00
NeilandOrca 17b3dff3c4 refactor(terminal): return IME composition ownership to xterm (#13128)
* fix(terminal): return IME composition ownership to xterm

* fix(mobile): derive terminal input from native replacement ranges

* test(mobile): record iOS Japanese IME traces

* fix(mobile): preserve native IME replacement ranges

* fix(xterm): flush queued application input after IME commit

* test(terminal): pin Korean intermediate commit

* test: pin Windows IME shortcut ownership

* test: replay IBus number candidate commit

* fix: preserve native macOS input-method punctuation

* refactor(terminal): remove stale mac focus override

* fix(mobile): preserve soft keyboard deletion ranges

* fix: keep IME-owned palette chords in renderer

* fix: stop carried IME shortcuts at renderer owner

* fix: preserve carried IME shortcut dispatch

* fix: narrow main-owned shortcut actions

* test(mobile): pin Japanese IME replacement traces

* test(terminal): retain paired native IME trace

* fix(chat): preserve browser IME composition ownership

* fix(chat): retain macOS IME confirm gesture

* fix(chat): expire unmatched IME confirm carry

* fix(chat): isolate IME confirmation expiry

* fix(chat): retain active IME confirmation

* refactor(terminal): remove dead composition handler

* feat(ime): add shared Enter-ownership seams for CJK composition

The confirming Enter of a CJK composition arrives as two keydowns and the
orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13
before keyup, macOS delivers keyup first. A guard reading only isComposing or
keyCode 229 misses the redispatch, so surfaces submitted on a confirm.

Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared
ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering
18 CommandInput surfaces at one site.

A chorded Enter arms the carry but is never swallowed — the reverse would eat a
user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by
ime-enter-gesture-ownership-contract.test.ts.

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

* refactor(terminal): consolidate native input listeners and parked-screen owner

Extracts the shared native-input listener installer and renames the parked-screen
detector for what it actually does, replacing per-call-site duplication. The
listener installer keeps a forgetOptionKeyLocationOnBlur flag so per-window
semantics are preserved rather than flattened.

Net deletion; no behaviour change intended.

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

* test(terminal): pin recorded IME shapes as regression tests

Nine regression tests built from hashed affected-platform captures, each with a
paired ordinary negative and a discriminating mutation verified to take the file
from all-passing to exactly one failure.

Covers the Windows MS-Korean Shift family (#12179, #11878, #12151, #11946,
#12152) and the Korean TUI line-break rows (STA-3237, STA-3222, STA-3129).

STA-3237 pins the empirical 3-Shift / 2-active-composition / 2-newline ratio the
device run established — the third Shift produces nothing because Space has
already committed. That ratio is not derivable from a static capture.

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

* fix(ime): guard Enter-commit surfaces against CJK confirm

Applies the Enter-ownership guards across the surfaces whose Enter commits
something: publishes, clones, pairs, installs, posts, or persists.

Tiered deliberately rather than uniformly. Irreversible and remote-effect sites
take the carry token, which also blocks the unmarked redispatch. Locally
reversible sites take the oracle check with a one-line comment naming the
residual, because a spurious commit there costs one undo.

Three numeric fields are left unguarded with the reason in-code: Chromium blanks
number inputs at compositionstart, so a confirm-Enter only ever reaches an
empty-draft reset. Measured with a CDP probe rather than assumed — a guard that
cannot fire is noise.

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

* test(ime): teeth-check the Enter guards on every guarded surface

One suite per guarded surface, each verified by deleting the guard and
confirming the test fails. A green guard test without that check is unverified,
not verified.

Two shapes pass vacuously in happy-dom and are avoided here: native implicit
form submission never fires, and blur() is inert on an unfocused element. Both
made "the commit did not happen" assertions pass with the guard removed, so the
suites assert the guard's contract directly instead.

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

* fix(mobile): keep iOS Korean commits whole through the live-input path

iOS Korean reports isComposing: false on every event, so it bypasses the
composition guard entirely. The strict owner rejected UIKit's transformed
post-change field and sent only the leading jamo — the reported symptom.

Prefers the authoritative same-event field text over the predicted text when the
supplied operation cannot produce it. Generic: no Korean special-case, no locale
classifier, no normalization. Adds the RN-target-keyed submit carry alongside it.

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

* test(e2e): make IME capture harnesses fail loudly instead of silently

Four instruments recorded silence as success, so a void run scored as a clean
one:

- readTerminalImeBoundaryTrace returned an empty trace when the probe never
  installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
  three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
  failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
  silently downgrading a PTY assertion

Input sources now resolve by enumeration and name the near-matches on failure.

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

* test(terminal): cover Cangjie cancellation and fix a cross-namespace assertion

Adds #11951's recorded Cangjie cancel shape to the existing cancellation suite,
which covered Pinyin and Sogou but not Cangjie. One keystroke then Backspace
arriving as deleteContentBackward with data: null, so the stale preedit is the
only thing a fallback could replay.

Verified against the historical pre-6cd944c62b3 bundle: the positive fails with
['尸'] where [] is expected, while the ordinary negative stays green.

Also fixes the Vietnamese spec, which asserted a TIS-space input-source ID
against getKeyboardInputSourceId(). Those two Orca APIs report the same source
in different namespaces — TIS nests it under VietnameseIM, the app API does not.
The resolver stays as an installation precondition; the assertion matches the
leaf.

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

* test(e2e): add a real-IME macOS arm for the Korean chord commit

The existing korean-ime-terminal-shift-enter-commit spec synthesizes composition
over CDP: Input.imeSetComposition sets the preedit directly and Input.insertText
performs the commit. Asserting the IME produced events you injected yourself is
circular, so that spec cannot certify real-IME behaviour.

This arm selects 2-Set Korean via TIS, reads it back live, and injects through
System Events key codes, so the OS owns the preedit, the commit instant, and
isComposing. PTY byte expectations are preserved verbatim.

Covers 2 of the original 4 cases by design. The other two are the Windows/Linux
redispatch-before-keyup ordering, which macOS cannot produce and which cannot be
selected -- the OS decides it. Reintroducing synthesis to "restore coverage"
would reintroduce the circularity.

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

* test(e2e): assert the macOS chord arm at the PTY boundary, not the renderer

The byte expectations were transcribed from korean-ime-terminal-shift-enter-commit
:364/:383, which assert against onData -- a renderer boundary where the terminator
is CR. This spec reads the PTY child, where the tty has already converted CR to LF.

Names both forms per row rather than swapping the constant, so the conversion reads
as evidence that the capture reached past the renderer, as #11936 and #11951 record.
Ctrl+Enter's CSI-u sequence is unaffected and is identical at both boundaries.

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

* test(e2e): measure composer-to-onData latency and stop dropping IME keystrokes

Two defects in the echo latency probe.

It hooked onWriteParsed and onRender but never onData, so it measured
key->parse->render echo rather than the composer-vs-onData delta the latency rows
need. Adds a third hook feeding its own sample set.

And `event.key.length !== 1` silently dropped IME keystrokes: Pinyin and Cangjie
keydowns arrive as key:'Process' (length 7). Replayed over the captured corpus,
the old filter accepted 580 of 4137 Chinese IME keydowns -- it was discarding 80%
of them. The new filter matches the shape the owner itself branches on.

Attribution charges each onData to the latest keydown rather than a FIFO head,
because composing jamo emit no onData at all and a queue would credit a whole
composition to its first keystroke. The consumer now asserts sample count before
any percentile, so a zero-sample run cannot render as a flawless distribution.

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

* test(terminal): pin the WSL shifted-jamo newline shape for #11919

In Korean 2-set, Shift types ordinary letters -- the double consonants and the
compound vowels. Each such keystroke reaches Chromium as key='Process',
keyCode=229, shiftKey=true.

The v1.4.163 classifier matched exactly that pattern with no code guard, so it
called those keystrokes Enter, rewrote them to a synthetic Shift+Enter, and
injected a newline into the middle of the word -- with no Enter key pressed.
That is why the reporters said "no modifier key pressed": they had not chorded
Shift+Enter, but they had pressed Shift, to type the double consonant.

Asserts the row's own recorded capture: 40 immediate keydowns, exactly 3 of them
Shift-carrying inside a single syllable, and an onData stream with one newline
per Enter press and none mid-word. Two ordinary negatives keep it from being a
blanket mute -- the same session's non-IME keydowns still reach shortcut policy,
and an ordinary Shift+Enter still resolves through the real policy.

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

* test(terminal): pin the composition commit lag that made Korean type one behind

macOS Korean 2-Set commits syllable N only when the first jamo of N+1 arrives, so
compositionend and compositionstart land in the same task. A composition-start
handler cancelled the pending finalizer that was the only path to triggerDataEvent
and ended the session without emitting bytes, so every committed syllable reached
onData exactly one syllable late and the backlog cleared only at a Space or Enter.

Types continuously with no Enter and no Space -- either would flush the backlog and
hide it -- and samples onData at every syllable boundary. Paired with a
length-matched ASCII arm that stays green throughout, so the positive is a fact
about composition rather than about timing in general.

Bisected to a single call site across five builds: pristine, 1.4.155 and 1.4.162
pass, 1.4.163 fails, removing the one call repairs it, restoring it fails
identically. That window is exactly the reporter's "started immediately after
updating".

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

* test(mobile): cover the send-queue abort that silently drops queued keystrokes

One failed send in use-terminal-live-input-commit aborts every keystroke
queued behind it, with the error swallowed by .catch(() => false). The
existing test resolves(true) on every send, so the failure branch was
uncovered.

Four arms: the abort itself, an ordinary negative on the healthy path, a
throwing sender, and a liveness control proving the queue recovers once
the chain settles. Deleting the abort takes 4 passed to 3 failed, with the
ordinary negative correctly surviving.

Scope is stated in the docblock: this is a transport send-queue abort,
reachable only via a real disconnect or RPC error. REQUEST_TIMEOUT_MS is
30s, so latency alone cannot reach the branch — consistent with #7094's
symptom class, not proven to be its cause.

* test(terminal): pin that daemon snapshot/restore cannot disturb a composition

Two independent reporters attributed broken Korean composition to the
always-on PTY daemon repainting terminal state over the preedit. The
attribution is wrong on ancestry — the daemon shipped three months before
the version both call good — but the boundary was never actually tested.

Runs the real applyMainBufferSnapshot choreography against a live
composition, including the full 2J/3J/H wipe plus the resize and
alt-screen branches. textarea.value, selectionStart/End,
compositionView.textContent and .active all survive byte-identical, and
interleaving a restore between every jamo of 문제 still commits 문제 at
onData. Also pins that the uncommitted preedit is absent from the captured
snapshot: it lives in the textarea, never the buffer, so a restore has
nothing stale to echo back.

Injecting one textarea.value = '' into the restore fails exactly the three
restore-boundary tests.

* test(terminal): pin that Cmd tears down a composition where Ctrl and Shift do not

xterm's composition keydown exempts only keyCode 16/17/18 (Shift/Ctrl/Alt)
plus 20/229. macOS Meta — 91/93/224 — is absent, so a Cmd press mid-composition
takes _finalizeComposition(false): the overlay goes dark and never recovers,
because compositionstart is not re-fired. The user composes the rest of the
word blind. Linux and Windows users press Ctrl and are exempt.

xterm already has a Meta-aware modifier predicate in wasModifierKeyOnlyEvent,
so this is an internal inconsistency rather than a deliberate choice.

Owns no reported row and is version-neutral: 5/5 on both 1.4.162 and 1.4.163.
The branch is unexercised in all 328 recorded traces, so this is a hazard pin,
not a regression guard. Only the teardown is asserted; the likely duplicated
commit needs a compositionend the IME kept alive across the Cmd, which no
capture contains.

Deleting the exemption fails exactly the three paired negatives; adding Meta
to it fails exactly the two Cmd arms.

* test(native-chat): characterize preedit loss when a question card replaces the composer

An AskUserQuestion card fully replaces the composer by design, but the
in-flight composition goes with it: the composer unmounts before
compositionend reaches it, so the preedit is never committed to the draft.
The committed text survives only because the draft is cached and restored
via defaultValue. Node identity changes, value 'abc' is preserved, the 가
is gone.

Drives the real NativeChatView -> SessionGate -> InteractiveCard ->
questionActive swap -> Composer -> ComposerField, flipped by writing the
same store field an AskUserQuestion hook event writes. Flipping
questionActive to false fails exactly this test and nothing else across
639 native-chat tests, so the path was entirely unguarded.

CHARACTERIZATION TEST: it asserts the loss. Fixing the defect — committing
the preedit before the swap, or keeping the composer mounted — will make
this file fail. Update the expectations to the new contract rather than
working around them.

Owns no reported row. #12118/STA-3219 flicker is keyed to token counters,
which provably do not remount, and a question card arrives once per
question.

* test(terminal): pin the duplicated commit when Meta interrupts a composition

_finalizeComposition(false) sends textarea.value.substring(start, end) but
cannot clear the IME-owned textarea, so a later compositionend re-sends the
same range. Meta reaches that path because CompositionHelper exempts only
Shift/Ctrl/Alt; xterm's own wasModifierKeyOnlyEvent covers Meta four ways,
so the omission is an internal inconsistency rather than a choice.

Companion to the modifier-exemption guard, which deliberately pins only the
overlay teardown. This pins the data consequence.

HAZARD PIN: owns no reported row. The trigger is unverified on hardware —
no capture in the corpus contains a Meta-during-composition gesture, and
whether macOS keeps the composition alive across it is unmeasured. The
duplication follows from the code given that sequence; whether users reach
the sequence is the open half.

An earlier premise that Space (keyCode 32) reaches this path was refuted by
a corpus scan: 0 of 731 evidence files carry a keyCode-32 Space while
composing, against 171 at 229, and 229 returns early.

* test(terminal): characterize the syllable lost when the textarea blurs mid-composition

CoreBrowserTerminal._handleTextAreaBlur clears the helper textarea
unconditionally — "Text can safely be removed on blur" — while
CompositionHelper._finalizeComposition reads the committed text back out of
that same value from a deferred timeout. By the time it runs the value is
empty, the substring is '', and triggerDataEvent never sees the syllable.
xterm checks composition state in _syncTextArea and omits the same check
here.

Six cases. Blurring mid-composition loses the syllable in every ordering,
including compositionend-before-blur, which is Chromium's real order — so
it is not an ordering artifact. A bare textarea.blur() with no Orca code
loses it too, which places the owner upstream: Orca's unguarded release on
outside pointerdown is one trigger, not the cause. Committing 한 then
blurring mid-가 yields ['한'] where ['한','가'] is correct: one syllable
gone, surrounding text intact.

Teeth checked by inverting — adding an Orca-side composition guard flips
exactly the three cases that route through the release path and leaves the
bare-blur and no-blur cases green, which is the scope split: a fix in
regular-terminal-focus-ownership alone would not close this.

HAZARD PIN, but unlike the others this one has a real production injector —
clicking outside the terminal mid-composition. Owns no reported row. The
shape matches #9738's report; the injector does not, and a shape match with
a mismatched injector is not an owner.

* test(terminal): say which arm the STA-3237 fixture came from

The recorded keydowns are wave 4's A-shift-unmarked-only — the arm that
emits no PTY bytes. Nothing in the file said so, so two readers concluded
the row's events fail the owner's predicate and that STA-3237 and STA-3222
were different defects. They share an owner; the arm that fires is
Process/229+Shift, absent from this bubble-phase trace because the owner
claims it in the capture phase.

Also corrects "code-blind": the v1.4.163 policy emits \x1b\r only for a
shift-only key:'Enter', and a jamo keydown reaches that branch solely via
the isTerminalImeProcessEnter rewrite. The mock is deliberately wider so
the ownership guard stays under test if that rewrite moves.

Comments only — no assertion, fixture value, or mock behaviour changed.

* test(e2e): track the input-source selector the macOS specs shell out to

Five tracked macOS IME specs ran `swift .tmp/select-input-source.swift`, a
file that is gitignored and existed only on one machine. Anyone else
checking out the repo — or the same machine after .tmp is cleaned — could
not run them, and they are the capture drivers for the macOS rows that are
blocked waiting for exactly those runs.

Moves it to tests/e2e/ beside its callers. The chord spec now resolves it
from __dirname rather than reaching two levels up into .tmp.

* test(terminal): pin the CJK repaint decision against the reporter's own output

#12164 comment 1 and #5921 report agent output with double-width glyphs
rendering duplicated character-by-character while ASCII in the same line
stays clean. No IME, no composition, no keystroke — the user never types
the CJK.

Segmenting all three verbatim samples into maximal same-risk-class runs
gives 33 runs and zero violations of "this run is corrupted iff the
production detector flags it": 17 wide runs all corrupted, 16 narrow runs
all byte-identical. The paired negative is co-located in the same line
rather than in a separate run — the reporter supplied it without knowing.

Doubling is asserted as present, not uniform: 자바스크립트 and 시스템 each
leave a jamo undoubled, which is a repaint-region boundary artifact rather
than a per-character transform.

The discriminating arm is in the test rather than a source mutation:
be3f30e2f8 (#6890) elects a repaint for all 17 corrupted runs when the
agent types nothing, and reverting its disjunct elects none. Both
predicates agree once the user has recently typed, which is the pre-#6890
condition.

Samples inlined with per-sample SHA-256 because .tmp is gitignored and
cannot back a landed test.

* test(terminal): pin macOS period substitution landing after the composition

#11504's reporter published a DOM trace showing insertText ". " arriving
149ms after compositionend, when two spaces are typed with a CJK input
source and NSAutomaticPeriodSubstitutionEnabled is on. This replays that
trace against a real Terminal and asserts what reaches onData — bytes to
the PTY, not anything visual.

The owner is stock upstream CoreBrowserTerminal._inputEvent, not an Orca
module, confirmed at the resolved install and in the shipped bundle Vite
loads rather than in the TypeScript source.

Three mutations against that install, predictions written before the runs,
each failing exactly the arms predicted: dropping the composed/keyDownSeen
guard fails two, dropping Orca's intercept fails the one arm where the
payload arrives before the send window drains, and flipping || to && —
the candidate-fix shape — fails the arm that pins the defect itself.

CHARACTERIZATION: arm 1 asserts the broken behaviour and will fail the
moment #11504 is fixed. Update it to the new contract rather than working
around it.

composed is absent from every recorded bundle, so composed: true is the
spec-required value rather than a captured one; the test asserts it before
dispatching so a harness that dropped the field fails loudly.

* test(terminal): replay the recorded Windows Shift sessions through the IME guard

STA-3179 reports a Shift release sending Enter; #12171 reports delayed
Hangul plus doubled newlines. Both replay their own recorded Windows
MS-Korean keydowns through resolveTerminalKeyboardShortcutAction with the
shortcut policy mocked, so the assertions are about which events reach the
policy and what reaches terminal input.

STA-3179's held-Shift gesture yields exactly one newline, from the unmarked
Enter alone; its release arms nothing for the next composition, asserted
after a precondition check that the release really is keyups with shiftKey
already dropped; and an ordinary Shift press-and-release still routes every
keydown, which is the paired non-IME negative.

Teeth, verified by mutation: bypassing the isImeOwnedKeyboardEvent guard in
keyboard-handlers takes STA-3179 from 3 passed to 2 failed / 1 passed — the
survivor being the ordinary-session negative, which is correct, since a
non-IME session should not depend on that guard — and #12171 from 2 passed
to 2 failed. Source restored byte-identical.

Recorded shapes are inlined and the bundles cited in comments; nothing is
imported from .tmp, which is gitignored.

* test(native-chat): correct 61977d4517 — the preedit survives the question card

61977d4517 claimed a composed syllable vanishes silently when an
AskUserQuestion card replaces the composer, and characterized that loss.
The claim was false. Its premise was an artifact of the harness: the test
simulated a preedit with a silent textarea.value assignment and no input
event, which no IME does.

Real composition fires input with insertCompositionText on every keystroke
— the shape this repo already records in its own observed-event capture —
and React's change handler returns on input/change with no composition
gate, so onChange runs for each frame. The draft cache is written
synchronously inside the updater, so the preedit is already committed
before the card can arrive. Driven that way, it survives.

Renamed to match the contract that actually holds, and extended: Hangul
jamo-per-frame, Japanese kana accumulation followed by per-segment
conversion asserting the candidate the user was looking at survives, and a
pin on the mechanism itself — the draft cache holds the preedit while the
card is up.

Teeth: there is no fix to revert, so the mutation is the plausible wrong
one — gating onChange on isComposing(). That takes 4 passed to 3 failed,
with the English negative correctly surviving, since it has no composition
to gate.

Two consequences remain, recorded rather than fixed: the OS aborts the
composition when the field disappears, so a lone jamo returns as a
compatibility jamo the user cannot compose onto, and the remounted
composer is unfocused because the card owned focus.

* docs(native-chat): name the corrected commit and the degraded-jamo consequence

Records in the file itself that 61977d4517 is pushed and wrong, quoting
the two claims that are false, so a reader who finds it in git log reaches
the correction from the file that replaced it.

Also states the residual as a consequence rather than a curiosity: a lone
leading jamo returns as a standalone compatibility jamo (U+3131), which is
not a composable state — the user cannot resume the syllable, only delete
and retype. Preserved, but degraded into something unusable. That is the
note to find if a reporter ever describes exactly that.

The invariant these tests pin is not "the composer commits on unmount" but
"composition input events must reach React" — which is what a future IME
change would break, and is not visible from the swap site at all.

* test(terminal): replay the recorded macOS Telex commit boundaries

#6905 reports Vietnamese composed characters breaking in the terminal.
Replays the retained macOS built-in Simple Telex capture — recorded
selection and value set before each dispatch, since that is what the
commit range reads — and asserts what reaches onData: the first commit
alone, then through the real Enter, then the ASCII tail of the same run.
A code-point count would catch NFD normalisation.

ENGINE CAVEAT, stated first in the docblock: this is macOS built-in Simple
Telex, Telex only. The reporter's three named engines cannot run on the
platform they declared, and which macOS Vietnamese engine they used is
unconfirmed. This file certifies no engine, and does not imply VNI.

The owner is upstream's — CompositionHelper._finalizeComposition's
waitForPropagation branch — so the arms are copies under .tmp aliased by a
scratch config, with node_modules verified unchanged by shasum after every
run. Collapsing the range end onto its start fails all three; collapsing
the start to zero re-emits the first word into the second commit, which is
the reporter's "duplicated" direction. Different failure sets, so the
mutants are distinguishable rather than merely detectable, and the ASCII
assertion passes under both.

Falsifiability here is by mutation, not by a defective build: #6905 does
not reproduce at HEAD, so this has never been watched going red on a real
reproduction.

* docs(terminal): lead the #6905 test with its engine caveat

Comment-only. Moves the caveat above the source line so a reader meets what
the file does NOT establish before what it does — the capture is macOS
built-in Simple Telex, the reporter's named engines cannot run on the
platform they declared, and which engine they used is what gates this row.

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

* docs(terminal): record that the swallow eats a keystroke after Japanese conversion

This pin framed the swallowed insertText around Cmd interrupting a
composition. A differential through Japanese multi-segment conversion shows
it is broader: type a segment, convert, then press `a`, and the `a` is
lost. No modifier, no exotic gesture. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

Also records why it cannot simply be fixed. The suppression de-duplicates
IMEs that deliver their commit a task after compositionend, which a sibling
test pins; this swallow is that dedup's false positive, and the two events
differ only in payload, so no flag-timing change separates them. Both a
smaller redesign and a content-aware variant were built and measured — the
first duplicates on IBus, the second costs a reported row's test and is
blocked while the patch cannot be regenerated.

The Japanese arrays behind this are authored, not observed: no Japanese DOM
composition trace exists in the corpus.

* docs(terminal): a Japanese capture does exist — correcting dbeecb11be

That commit said no Japanese DOM composition trace exists in the corpus.
False. One does, filed under the Linux bundles rather than the bundle named
for Japanese: 30 DOM events, two にほんご->日本語 conversions, with full
selection state per event. It is retained byte-identically in three further
bundles — one capture copied four times, not four observations, checked by
hash rather than by counting files.

The claim came from checking the bundle named for Japanese, finding nothing,
and generalising to the corpus without querying the rest of it.

Replaying it emits 日本語日本語 under both sequencing extremes on all four
arms, matching its own recorded onData. So "repeated conversion is
undisturbed" is now captured rather than authored. It carries no
post-compositionend insertText, so it cannot speak to the swallow: the
a-after-conversion figure stays authored and unobserved.

Also rewords the paragraph opener. It claimed to broaden a Cmd framing, but
hazard 2 was never Cmd-framed — the lines above already say Cmd does not
reach it. The real gap was that hazard 2 named no trigger at all, which
reads as exotic when it is ordinary.

* build(xterm): land the patch regeneration harness

The five dependency patches under config/patches/ shipped with no tracked
way to regenerate any of them. The xterm one is the hard case: it is derived
from an upstream build, so no fix could be made without rebuilding, and the
tooling to rebuild lived only in one machine's scratch directory. That
blocked a measured fix for a live keystroke-loss bug, and the EditContext
reduction an OSS survey identified as the only real one available.

Adds the regenerator, the upstream pin, the hand-written source patch the
bundle hunks derive from, tests, docs, and a PR job that verifies the
shipped patches still match the pinned build. The job caches the shallow
clone keyed on the manifest, so a cold run is minutes and a warm one under
one. Round-trip verified: regenerating from a clean checkout reproduces the
shipped patch byte-for-byte.

Marks the emitted patch -diff -text. pnpm hashes it byte-for-byte, so a
CRLF checkout would break install on Windows, and its minified bundle lines
make a diff nobody can read — review the source patch instead.

Also rejects unknown flags. --check was the fallback for any unrecognised
argument, so a typo, or --help, silently triggered a full upstream build
instead of what the caller asked for.

* fix(xterm): stop swallowing a keystroke typed after an IME commit

Type a Japanese segment, convert it, then press a key one macrotask later
and that key was lost. No modifier, nothing exotic — every user who keeps
typing straight after converting. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

handleCompositionInput discarded the payload unconditionally in the window
after the deferred send: _isSendingComposition stays true for one macrotask
after the timer cleared _pendingCompositionStart, and the branch substituted
'' for whatever arrived. The suppression is not itself wrong — it
de-duplicates IMEs that deliver their commit an event-loop turn late, which
terminal-stock-composition.test.ts pins. It just could not tell a duplicate
from new input, because the two events are identical apart from payload.

Now it compares against _sentComposition, the text the deferred send
actually emitted, and discards only a match. A flag-timing redesign was
measured first and rejected: it fixed this and duplicated on IBus, because
no timing change can separate events that differ only in content.

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and the lockfile hash are derived, not hand-written.

The commit-overlap pin's swallow arm now asserts the repaired contract —
the value its own comment already named as correct and as what stock
beta.287 emits. #11504's arm at :184 flips too; it never covered that
report, as its own prior note recorded, and the reporter's +149ms arm is
untouched and still asserting the defect. Provenance hashes in three test
docblocks are updated, since regenerating changes the patch hash and with it
the resolved install directory.

* docs(terminal): re-measure the #6905 mutation citations against the new bundle

Regenerating the patch moved the resolved install, so this docblock's
patch_hash, line count, two line numbers and three mutation outcomes all
described a bundle that no longer exists. The deferred branch is one the
fix writes into, so the outcomes could not be re-pointed on reasoning.

Line numbers read off both files by diffing anchors rather than derived by
arithmetic: 201 to 205, 159 to 163. Outcomes re-run through the retained
rig, which re-resolves through the module loader and re-derives each arm
from a unique minified anchor: pristine 3 passed, m1 3 failed, m2 2 failed,
m3 3 passed — identical to the old bundle. Guard controls in both
directions exit 1, so the counts are falsifiable.

Comment-only; the assertions and expectations are unchanged.

* fix(xterm): size the preedit overlay to the cells its text will occupy

updateCompositionElements computed the overlay's left edge from the grid
but never its width, so the preedit rendered at the font's natural advance
while the committed text takes two cells per wide glyph. Measured in
Chromium 150: 가나다라 drew 48.45px as a preedit and 69.20px once committed
— the same characters, same font, 30% narrower, and drifting further with
each syllable. Every macOS mono font carrying Hangul measured 0.49–0.72 of
two cells; never 1.0.

Deriving the width from wcwidth and the cell measure moves Korean, Japanese
and Chinese to 1.000 and leaves ASCII at 1.000, which it already was:

  한        12.125 -> 17.297   (17.30 expected)
  가나다라   48.453 -> 69.188   (69.20)
  안녕하세요 60.563 -> 86.500   (86.50)
  日本語     42.000 -> 51.906   (51.90)
  abcdefgh  69.234 -> 69.203   (69.20, unchanged)

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and lockfile hash are derived rather than hand-written.

The unit test asserts the arithmetic, which is what CI can run. The pixel
consequence was measured on macOS with SF Mono in an Electron harness, not
on the Windows font stack STA-3232 reports from — so this demonstrates the
mechanism and does not stand as that row's platform evidence.

* test(e2e): pin the macOS Korean preedit as visible only while composing

#11914 reports the composing text invisible until Space. Its c3 was recorded
as unobtainable, and the reason on file was wrong: the boundary IS
assertable, but not in happy-dom, which reports display:block in BOTH the
active and inactive states and zeros for every rect. A test there passes
with the defect present.

Captured on real hardware instead: hidden and 0x0 before, .active with
display:block, a 15.84x16 rect and checkVisibility() true while composing
그, hidden again after. 39 DOM events, 2 composition starts, onData
["한","그","\r"].

Two mechanism findings are carried in the setup because both are invisible
in the result and fatal if removed. The input source must be selected AFTER
the app takes focus — focusing resets it to ABC. And the IME must be warmed
until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m)
with no composition at all, which is indistinguishable from an IME that is
not installed. Two runs were voided on exactly that signature before the
warm-up was found.

The has229 and compositionStarts assertions exist to make such a run fail
loudly rather than pass as a clean negative.

Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The
final spec form has not itself been executed — the machine became
unavailable — so it carries the probe's measured values as literals rather
than a run of its own.

* docs(e2e): correct 19a8d133db — the Korean preedit spec has been executed

That commit said the landed form had never run and carried the probe's
values as literals. It has now run on real hardware: 1 passed, 9.1s, rc=0,
with the capture and log sealed under a verified hash manifest.

The teeth check was also run rather than reasoned about, and it changes
which assertion matters. Forcing the active overlay to max-width:0 with
overflow:hidden — invisible on screen — leaves the active class, the
textContent, display:block AND checkVisibility() all passing. Only
during.rect.width fails. checkVisibility() is not sufficient against this
defect; the bounding rect is the single load-bearing assertion, which the
docblock already said and this run confirms.

An earlier teeth attempt injected the CSS mid-run and tripped the
hasActiveClass poll instead, failing at the wrong assertion. It is
inconclusive and excluded from the seal rather than counted.

* test(terminal): add #12171's ordinary-English arm from a real Windows capture

c4 was recorded as unmet and the ledger sourced its control to
evidence/windows-current/, which holds 12 captures and not one English one.
The arm here comes from windows-9803-final instead — same probe, same host
geometry, same injector, en-US with no IME, replayed keydown for keydown.

Two limits are stated in the file rather than left for a reader to find. It
is a different bundle and a different run about 3.6 hours later, so it is
not a same-run arm. And it is #9803's range-active MUTANT arm: ordinary
English stays byte-exact even with that saved-range mutation live, which is
why it reads as a negative rather than as a baseline.

Bundle cited by directory with its file SHA-256; MANIFEST.sha256 verifies
21/21, rc=0. Nothing imported from .tmp.

* docs(terminal): correct #12164's grounds — the cited comments say no such thing

The rejection of #12164 from this file's family was recorded as resting on its
comment 1 (output doubling) and comment 2 (filed against 1.4.163). Checked
against the API: the issue has exactly two comments, neither of which says
that, and the string 1.4.163 appears nowhere in the thread.

The conclusion survives on better grounds. The issue BODY's repro is "Run any
CLI agent (Codex, AGY, Claude, etc.) that outputs Korean text into the Orca
terminal" — untyped output, no keystrokes, no composition — so excluding
CompositionHelper is right, and the input-path hunt was looking in the wrong
place. The body is also LLM-authored (it still contains a literal
"## 5. GitHub Submission Draft (Ready to Post)") and its Root Cause section
blames a CJK IME preedit buffer its own repro never engages, so it should not
be read as observation.

Comment-only; suite unchanged at 5/5.

* test(native-chat): make composition frames carry isComposing, not just inputType

This suite's comment claimed "Gating onChange on `isComposing` breaks here."
It did not. composeFrame() fired `input` with `inputType` but never set
`isComposing`, so a gate on `isComposing` passed all four tests untouched —
the suite asserted a discriminator it did not exercise.

Composition frames now carry both, so neither gate is exempt. Verified by
pointing the mutant at it: with an `isComposing` gate on the composer's
onChange, this suite now fails 3 of 4 (it passed 4 of 4 before), and the
ordinary-English arm correctly survives, since a composition gate should not
touch it. Production code is unchanged and stays gate-free; the mutation was
applied, measured, and reverted.

Found while excluding NativeChatView's question-card remount as the owner of
#12118 / STA-3219: the remount is real, but the preedit survives it precisely
because this write path has no composition gate.

* fix(mobile): ship the patched xterm build, matching desktop

mobile pinned @xterm/xterm 6.1.0-beta.285 while the patch is keyed to
6.1.0-beta.287, so mobile shipped stock xterm and neither IME defect fix
reached it: the swallowed keystroke after an IME commit (9506039de7) and
the preedit sized to the font rather than the grid (e04e0c88da).

Bumps the three xterm packages to the desktop versions and adds the patch
to mobile's own pnpm.patchedDependencies. No copy of the patch: pnpm
accepts the parent-relative path and records it in the lockfile against
hash 8d63166272e9040a…, byte-identical to what desktop resolves, so the
two stay in step by construction rather than by a drift check.

The workspace separation is untouched — root pnpm-workspace.yaml still
declares `packages: []` and mobile keeps its own lockfile, which is what
keeps the root's patches from failing as ERR_PNPM_UNUSED_PATCH.

Verified in the generated webview bundle rather than at the install:
alignPreeditToGrid 0->2, sentComposition 0->3, pendingInput 0->11, and the
stock-only _handleAnyTextareaChanges 2->0 and dataAlreadySent 4->0. pnpm
applies patches during linking before postinstall regenerates the bundle,
confirmed by a revert/reinstall/re-apply cycle in both directions.

Mobile suite 2971 passed, 3 skipped — identical before and after. Bundle
+1,514 B (+0.24%). Lockfile churn is xterm-only; --frozen-lockfile passes.

mobile/src/ime/ime-submit-carry.ts is NOT made redundant and is untouched:
it handles iOS firing onSubmitEditing on a React Native native TextInput
after unmarking a composition, which is outside the WebView entirely.

Known divergence left alone: desktop also patches @xterm/addon-webgl and
mobile now runs that version unpatched. That patch is glyph/texture-atlas
rendering with nothing IME-related, so it affects neither fix.

* test(terminal): pin the preedit overlay against already-committed cells

STA-3132 (arm A), STA-3170 and STA-3232 report a Korean preedit painted on
top of text already on screen. Builds v1.4.163-v1.4.166 cancel the pending
finalizer in compositionstart, so a committed syllable reaches onData one
syllable late and buffer.x is stale — the overlay lands on the cell the
flushed syllable is about to occupy.

Replays a recorded hardware trace rather than an authored one: the ordered
DOM event stream captured on Windows + MS Korean (wave5-r2 evidence, 64
events), echoing onData back as PTY output.

The load-bearing assertion is deliberately not the obvious one. Comparing
overlay style.left against cursorX is tautological — left is computed from
buffer.x. This counts committed syllables from the compositionend events
the IME fired, so the two sides are independently derived.

Discriminated by a historical re-add across seven real bundles, since the
owner is deletion-shaped: pristine beta287, v1.4.155 and v1.4.162 pass;
v1.4.163 fails; v1.4.163 with that single call removed passes; the byte
identical baseline restored fails again; head passes. Every failing arm
fails only this case — the ordinary negative stays green in all seven.

The negative asserts its own category rather than claiming it: zero
composition events, zero isComposing, zero keyCode 229, exactly 16 events,
paired against the Korean arm's 4 starts / 3 ends / 11 updates / 64 events.

Scope: cell indices, not pixels. happy-dom has no layout, so the recorded
8x16 cell metrics are supplied to the render service. This makes no claim
about pixels visually overlapping; that is affected-OS confirmation and
stays open. Covers the overlap arm only — STA-3132's auto-line-break arm
and STA-3232's half-line-capacity and a11y arms are untouched.

* test(e2e): matrix macOS period substitution against the OS preference

#11504 reports macOS inserting ". " after a Hangul Space commit. This
sweeps six arms across both states of NSAutomaticPeriodSubstitutionEnabled,
reading the preference live per run rather than asserting a literal.

Two results worth having on record.

The reporter's stated trigger did not reproduce. Their words are "There is
no second press at all. One space is enough", but korean-single-space emits
zero insertText with the preference on or off. So does word-space-word-space.

Their timing does reproduce, with different content. korean-double-space and
korean-longer-word-double-space emit a delayed insertText at +122.5-122.7ms
after compositionend — squarely the reported +149ms — but the payload is a
space, never ". ". Consistent with the double-space rule seeing two slots
under ABC and only one under Korean, where the IME commit consumes the first.

The substitution itself is real and preference-bound: latin-double-space
gives "ab . " with the preference on and "ab  " with it off, on one build
with the preference as the sole variable, reproduced across two runs.

That also refutes a claim in PR #11506, which states the substitution "is
enforced outside the renderer and never reproduces in dev builds, so changes
here must be verified against a packaged app". It reproduced in the dev build
twice and did not reproduce on the signed packaged app. That claim should not
be used as a verification gate.

Gated @headful behind ORCA_E2E_NATIVE_MACOS_PERIOD, same shape as the Korean
preedit spec, so it does not run in ordinary CI. Evidence is onData and DOM
only — the PTY-child reader aborted and no packaged-app arm was stable.

* test(terminal): actually enforce the recorded jamo progression

The preedit assertion compared sample.overlayText against sample.overlayText
— the same expression on both sides. A lane proved it by mutation: corrupting
seven of the eight recorded preedit values left the suite fully green. So the
docblock's ㄱ→가→간→나→낟→다→달→라, which the matrix also cites as this row's
recorded shape, was cited and unenforced.

The first attempt at a fix was insufficient and is worth recording. Threading
stroke.preedit through to the expectation still passed on a corrupted fixture,
because that value both drives the rig and was the expectation — corrupting it
moved both sides together. Same tautology, one level down.

The expectation is now an independent literal. Verified by mutation rather
than by reading: corrupting two recorded values fails one arm; restoring them
passes 3/3.

overlayCell was never affected — it is compared against a count derived from
the compositionend events, not from the buffer, and remains the load-bearing
assertion for the overlap.

* fix(e2e): select the selectable input source, not the first match

TISCreateInputSourceList can return several entries for one input source
id. A third-party IME publishes a non-selectable parent alongside the
selectable mode, and taking sources.first can return the parent — after
which TISSelectInputSource fails with paramErr (-50) while the caller
reports success from the enable step.

Found with Qingg (com.aodaren.inputmethod.Qingg), which exposes exactly
that pair under one id. Its mode id equals the bundle id, so filtering by
name would not have helped; selectability is the discriminator.

Now filters on kTISPropertyInputSourceIsSelectCapable and falls back to
the old behaviour when nothing advertises it, so single-entry sources are
unaffected. Also enables every entry for the id rather than only the one
being selected: selecting a mode whose parent is still disabled fails the
same way.

Compile-checked, and selecting com.apple.keylayout.ABC still exits 0.

Unrelated to the enable path: on macOS 26.5.2 third-party IMEs are gated
behind a consent sheet in System Settings. TISEnableInputSource returns
noErr immediately regardless, and the enable only lands if that sheet is
answered while the requesting process is still alive.

* test(terminal): discriminate #12171 against the real shortcut policy

The prior candidate mutation for this row was correctly refused: its suite
mocked shortcut policy so Process/229 became actionable, while the real
resolveTerminalShortcutAction has no Process branch — so the kill measured
the mock. This does not mock it.

Replays a capture of this row's own gesture (d, l, Shift+T, e, k, Space,
Enter under MS Korean, committing 있다) taken on Orca 1.4.164, through the
real useTerminalKeyboardShortcuts hook, capturing bytes at terminal.input.
The earlier capture could not discriminate at all because it recorded no
shiftKey; this one records it on 10 of 10 keydowns with code populated.

One physical Shift+T produces two shifted Process/229 keydowns. Under the
pre-#12265 classifier each synthesizes {key:'Enter', shiftKey:true}, which
the real policy resolves to sendInput '\x1b\r' — twice, giving 1b0d1b0d,
the two escapes the known-bad ed96881b0d1b0d contains.

Mutation is the retained pre-12265-process-shift.patch applied to HEAD, not
an authored one: patch -p1 applies clean and diffs identical to the mutant
copy. Arms are copies; shared source hashes the same before and after.

The English arm stays clean under both modules, so the mutation
discriminates by language rather than by harness — and a real Shift+Enter
through the same rig yields exactly ['\x1b\r'] in every arm, so a silent
pristine result means the code is quiet rather than the harness dead.

Scope: 1b0d1b0d is measured at the renderer boundary. The capture recorded
no PTY bytes — window.api.pty is frozen on shipped builds and the onData
channel needs a build-time flag — so this shows the renderer producing the
two escapes that payload contains, not a re-observation of the payload.

* docs(native-chat): narrow this file's disclaimer to what is now true

It said "THIS OWNS NO REPORTED ROW". Half of that is stale: the remount site
is now the attributed owner of #12118 and STA-3219. On real Windows TSF the
questionActive swap aborts a live composition — the old node gets only a
blur and no compositionend, the text returns as committed, and the next jamo
yields 아ㄴ rather than 안.

The other half holds. This file pins the opposite property, that the text
survives, which is the half those reporters already agree with. Mutation
shows the gap rather than asserting it: deleting the unmount entirely leaves
three of four tests green, because every substantive assertion is
after.value === … and a composer that never unmounts keeps its value.

Also records why the abort cannot be asserted here. The DOM exposes no
observable separating committed text from a live preedit — value is the same
string either way, there is no EditContext, and the only composing-ness
state is a per-instance ref discarded with the node. A test pinning "no
compositionend fires" would be an anti-guard: red the day it is fixed.

The cadence objection is kept, since it is now the open question rather than
the reason for exclusion.

* refactor(terminal): drop the unread isComposing field from XtermBypassEvent

Added by #6396 for terminal IME candidate handling that this branch has since
removed. No production or test code reads it, and the policy is safe without it:
during composition `key` is 'Process', so the non-ASCII printable checks that
would care never match.

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

* fix(native-chat): keep the composer mounted through an in-flight IME composition

A question card replaced the composer outright
(`{questionActive ? null : <NativeChatComposer/>}`). Unmounting the field
mid-composition aborts the composition in the OS: the node is detached before
`compositionend` can fire, the preedit returns as committed text, and a resumed
Hangul syllable degrades — 아 then ㄴ yields `아ㄴ`, never `안`.

Confirmed in rasterised pixels on Windows with a real MS Korean IME, at both
v1.4.171 and the reporter-era v1.4.164 (the swap block is byte-identical
across them): the preedit underline present before the swap, the composer
visibly absent during it, and the same glyph back afterwards WITHOUT the
underline — committed, not composing.

The swap is now deferred while a composition is in flight, which is what
editors that survive IME do: ProseMirror gates DOM work on `view.composing`,
CodeMirror protects the composing subtree from redraws. Hiding instead of
unmounting does not work — `display:none` and `visibility:hidden` both blur the
focused element and abort the composition the same way.

The hold releases on `compositionend`, which browsers also fire on blur, so
clicking into the card's own answer input yields the input region immediately;
with nothing composing the card still replaces the composer at once, so no
stray "Send a message" appears beside a question.

The existing characterization test flips to a regression guard: it pinned the
node being destroyed, which was the defect. Node identity is the load-bearing
assertion — value-only checks are trivially satisfied by a composer that never
unmounts and cannot tell a held composition from a destroyed one.

The typing-redirect handler moves to its own hook. That is not cosmetic: both
touched files sat at the 400-line cap, and `max-lines` suppressions are
forbidden, so the room had to come from a real extraction.

* fix(macos): opt Orca out of AppKit automatic period substitution

macOS "Add period with double-space" (`NSAutomaticPeriodSubstitutionEnabled`,
on by default) is applied by AppKit's text input system. Native terminals never
join that system; Chromium text fields do, so xterm's helper textarea inherits
it and a double space arrives as `". "` — a period nobody typed, handed straight
to the PTY (#11504).

Chromium answers AppKit for quote and dash substitution and defaults both off,
but declares no period accessor at all, so AppKit applies that one without
asking. This user default is the only lever: there is no per-field or
per-webContents opt-out to prefer over it. Writing the key into Orca's own
defaults domain overrides the global value for this app alone and leaves the
user's system-wide setting untouched. It necessarily covers every Orca text
field, not only terminals — AppKit offers no narrower scope, and that tradeoff
is deliberate rather than accidental.

Measured on the reporter's own build v1.4.161: with the preference ON, typing
a,b,space,space yields `onData ["a","b"," ",". "]`; with it OFF the same arm
yields two spaces.

Note the issue's causal model is wrong and this fix does not follow it. It
claims the substitution only fires with a CJK input source and never with ABC.
The measurement is the inverse — every Korean arm is clean and the ABC arm is
the one that fires — so the fix is not conditioned on input source.

NOT YET VERIFIED ON HARDWARE. The unit tests inject the writer, so they prove
the call is made on darwin and skipped elsewhere; they do not prove AppKit
honours an app-domain override for this key. That check is outstanding.

* fix(xterm): keep a live composition across a lone Cmd press on macOS

CompositionHelper.keydown exempted keyCodes 16/17/18 from tearing a composition
down, which covers Shift/Ctrl/Alt but not macOS Meta — 91/93 in Chromium, 224 in
Firefox. A lone Cmd press mid-composition therefore reached
_finalizeComposition(false), which dropped the preedit overlay's `active` class
and committed the live syllable early. macOS keeps the marked text alive across
that press, so no later compositionstart re-arms the overlay and the rest of the
word composes invisibly.

Measured on hardware (m4air, macOS 26.5.2, Apple M4, 2-Set Korean) with the Cmd
posted as a CGEventType.flagsChanged, which is what a physical modifier emits.
AppleScript `key code 55` posts nothing a browser can see — a bare `key code 56`
for Shift is equally silent — which is why no capture in the corpus ever reached
this branch. Three arms, same build otherwise: overlay live throughout with the
exemption, dark and prematurely committed without it, live again with it
restored. Evidence under
.tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/.

The fix cannot widen past a lone modifier: only a standalone press reports these
keyCodes, and a Cmd chord during composition is reported by Chromium as 229,
which was already exempt. Cmd+A still ends the composition, via the IME's own
compositionend. Ghostty draws the same line, returning early from flagsChanged
under hasMarkedText() for every modifier including Super.

Orca's terminal pane was never affected — shouldSuppressTerminalModifierKeyboardEvent
drops a standalone Meta keydown before xterm sees it, and deleting only 'Meta'
from that set is what flipped the hardware arm to broken. The popout preview
terminal and mobile's webview install no such guard and did reach the teardown.

terminal-ime-xterm-composition-commit-overlap.test.ts asked its fixer to update
the two Cmd arms to the values it named as correct; both now emit a single ['한'].

* test(native-chat): drop two byte-identical duplicate cases

`4632b86919d` copy-pasted two cases twice into the same describe block:
`retains carry across a same-frame non-Enter keyup before redispatch` and
`expires carry before a deliberate Enter after the next frame`. Each pair is
byte-identical — same title, same body — so the copies asserted nothing the
originals did not.

This is what has been failing `static analysis` on this branch since 2026-08-06:
`oxlint vitest(no-identical-title)` reports both under `--deny-warnings`, and
`verify` fails solely because it requires static analysis to pass. Every other
gate in `verify` was already green, including typecheck, xterm patch sync, the
full test shard set, and both package jobs.

12 cases still pass in the file.

* test(e2e): skip the WebGL arm when no WebGL renderer exists

The #12164 probe runs two arms, webgl and dom, and closes by asserting the
active renderer is the requested one. That assertion is right for the dom arm —
it is what proves the pane actually left WebGL, without which the arm is
meaningless — but headless CI has no GPU, xterm falls back to DOM silently, and
the webgl arm then fails.

The failure reads as a Korean rendering defect and is not one, so the webgl arm
now skips with the active renderer named. The dom arm keeps the assertion
unchanged.

This is the third of three checks that have been red on this branch since
2026-08-06. `static analysis` and `verify` were fixed in c51c6b5837e; the CI log
shows this job as 1 failed / 1 passed, the pass being the dom arm.

* chore(lint): drop five unused no-console disable directives

`check-changed-code-quality` reports unused eslint-disable directives as errors,
and these five sat above diagnostic `console.log` calls in IME test and spec
files where `no-console` is not enabled — so each suppressed nothing.

This is the second of the two static-analysis steps. `c51c6b5837e` fixed
"Enforce focused code-quality plugins" (duplicate test titles); this fixes
"Enforce changed-code quality". Both had been red on this branch since
2026-08-06, and I mistook the first for the whole job.

The diagnostic logs themselves are kept — they are what a failing IME arm prints
for a reader to inspect.

* test(e2e): cover #12164 under fractional device scale factor

Fractional display scaling was #12164's last unexplored branch, and the reason
is worth recording: earlier attempts were BLOCKED, correctly, because they
proposed mutating the Windows display scale on a remote physical machine with no
console recovery. `--force-device-scale-factor` reaches the same renderer state
per process, so nothing outside the Electron instance changes and there is
nothing to restore.

The hypothesis was specific: `프프로로젝젝트트` is what a half-pixel cell boundary
could produce on a 2-column glyph, and nothing else in the suite varies dpr.

Measured at 1.25 and 1.5, both under WebGL: ink extents 25/21/16 with identical
ink groups, matching the scale-1 run. No doubling.

The arm self-certifies before asserting — if the flag does not take, the test
fails rather than silently measuring at dpr 1. That matters here: the sibling
spec's WebGL arm went two days reporting a missing GPU as a Korean rendering
defect precisely because a silent fallback looked like a result.

* fix(terminal): match Mod+letter shortcuts by physical key, not IME-rewritten key

With a CJK input source active, macOS and Windows report the physical key through
`code` but rewrite `key` to the layout's character: Korean 2-Set turns Cmd+C into
`{ key: "ㅊ", code: "KeyC", metaKey: true }`. Every `key.toLowerCase() === 'c'`
match misses it, so the shortcut is not recognised and xterm encodes the chord as
PTY input instead — issue #13033 reports `ESC[12618;9u` and a terminal that jumps
to the bottom, because user input scrolls the viewport.

This is the same key-vs-code confusion that owned #12171, where a `Shift+T`
typing ㅆ was read as Enter for want of a `code` guard, so the fix is the same
shape: trust `code` when it is present, fall back to `key` and then the legacy
`keyCode` when it is not (Chromium omits `code` on synthetic and some keypress
events, and `keyCode` keeps its US value even when `key` is rewritten).

Applied to the four terminal-side sites, including the dashboard pop-out, which
#13033 called out specifically as having its own key handler:
  pty-connection.ts            Cmd/Ctrl+C copy guard
  keyboard-handlers.ts         Cmd+G search navigation
  agent-interrupt-inference.ts interrupt inference
  preview-terminal-key-handler.ts  pop-out paste

Nine further `key.toLowerCase()` letter matches exist outside the terminal
(TaskPage, editor, GitHub composer, browser markup). They have the same defect
and are deliberately left for a separate change rather than widening this one.

An existing case, `matchSearchNavigate > returns null for wrong key`, overrode
only `key` and left `code: 'KeyG'`, so it began passing for the wrong reason. It
now overrides both — which is what "wrong key" means once matching is physical —
and a companion case pins the Korean-rewritten chord still matching.

#13033 was closed NOT_PLANNED; the reporter's event shapes drive the new test.

* fix(renderer): match every Mod+letter shortcut by physical key, not IME-rewritten key

Completes the previous commit. A CJK input source rewrites `event.key` while
`event.code` keeps the physical key, so `key.toLowerCase() === 'z'` and friends
silently stop matching — the shortcut is not recognised and the keystroke falls
through to whatever handles unclaimed input.

The helper moves to `@/lib/ime-latin-shortcut-key` first: it now serves the
editor, GitHub composer and browser markup, and importing terminal-pane
internals into those would be the wrong direction. `lib/` already hosts
`ime-composition-keyboard-event` for the same reason.

Nine remaining sites, all previously unreachable under Korean/Japanese/Chinese/
Vietnamese input:
  TaskPage, ActivityPrototypePage, ProjectViewWrapper   Cmd+F search
  useMarkupKeyboardShortcuts                            Cmd+Z undo
  GitHubMarkdownComposer, RichMarkdownLinkBubble,
    rich-markdown-link-shortcut                         Cmd+K link
  native-chat-shortcut                                  Cmd+J
  rich-markdown-key-handler                             Cmd+Shift+X

Six of the nine test `!== 'letter'` as early-return guards and three test
`=== 'letter'`; the negation is applied per site, since a blind substitution
would have inverted six of them.

Full suite: 4249 files pass. Three files fail locally and none is caused by this
change — the branch touches no file under `src/main/` or `src/relay/`, all four
failures reproduce on an unmodified tree or pass in isolation (the worktree
poller passes 21/21 alone, so it is full-suite parallelism), and all 16 CI test
shards are green.

* docs(ime): scope the IME composition rules to the terminal-pane directory

#11893 proposed adding these to the root `AGENTS.md`, which every agent loads on
every task regardless of what it is doing. They only bind keyboard handling, the
composer and the terminal input path, so they belong next to that code —
`tests/e2e/AGENTS.md` already establishes the nested convention here.

Kept from #11893: range-derived commits, guarding above the key dispatch,
the `attachCustomKeyEventHandler` / `CompositionHelper` interaction, no
normalization at commit, and the recorded-trace evidence bar.

Added from defects found since it was written:
- match shortcuts on `event.code`, not `event.key` (#12171, #13033)
- `keyCode === 229` means an IME owns the press
- do not unmount a field mid-composition, and hiding is not a fix because
  `display:none` blurs and aborts it too (#12118, STA-3219, #11332)

The evidence bar now also names the mutation check, since a test that survives
deleting the code it guards is guarding nothing — a failure this effort hit more
than once.

* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated pane, porting #12462

Found while scoping the rebase onto `main`: #12462 landed on 2026-08-06 and
fixes a real defect this branch does not carry. Ctrl+Enter emitted
`\x1b[13;5u` unconditionally, so a pane that never negotiated the kitty
keyboard protocol — local Windows ConPTY, plain shell — printed the escape
verbatim into the prompt.

This branch deletes `terminal-ime-deferred-newline.ts`, which is one of the
files #12462 touched, so a rebase resolving those conflicts by taking our side
wholesale would silently reintroduce the defect. Porting it forward now means
the fix survives the rebase however the conflicts are resolved.

Mirrors the Shift+Enter guard already here: local ConPTY falls back to the
legacy CR every emulator sends, and a negotiated pane keeps the chord, so the
fallback is scoped to panes that cannot receive CSI-u rather than to Windows.

NARROWER THAN #12462 BY ONE CONDITION, deliberately. `main` also allows CSI-u
via `hasCtrlEnterCsiUAuthority()` (trusted consumer evidence, #12329); that
helper and its plumbing do not exist on this branch. Omitting it is the
conservative direction — an authorised pane gets `\r` instead of the chord,
rather than an unnegotiated pane printing an escape — but it should be restored
when the two histories are reconciled.

Test covers both directions and is mutation-checked: forcing the gate open
fails it, so it cannot pass by construction.

* fix: reconcile two more fixtures main moved while the stack waited

Both caught by CI, not locally, and the reason the local run missed one is
worth recording:

1. `browser-toolbar-profile-dialogs.ime-enter.test.tsx` did not pass
   `useNativeUserAgent` / `onUseNativeUserAgentChange`, which `main` added to
   `BrowserToolbarProfileDialogsProps`.

   Local `pnpm typecheck` reported 0 errors on the same commit CI failed. The
   cause was a stale `config/*.tsbuildinfo` — tsc reused an incremental cache
   from before the merge. Deleting it reproduced CI's error exactly. Any
   "typecheck clean" during this merge should be treated as unverified unless
   the cache was cleared first.

2. Localization keys for `SshDisconnectedDialog` were absent from `en.json`:
   the merge took this branch's component alongside `main`'s catalog.
   Regenerated with `pnpm run sync:localization-catalog` rather than hand-added.

* fix(mobile): regenerate the lockfile the merge resolved by taking one side

CI's `verify` failed with `ERR_PNPM_OUTDATED_LOCKFILE` on `mermaid (lockfile:
11.16.0, manifest: 11.16.1)`. The mismatch was in `mobile/`, not the root — the
root lockfile was consistent throughout, which is why inspecting it (and even
GitHub's merge ref) found nothing wrong.

Cause: during the merge I resolved `mobile/pnpm-lock.yaml` by taking this
branch's side wholesale rather than merging it, so it kept `mermaid 11.16.0`
while `mobile/package.json` came from `main` at `11.16.1`. Taking one side of a
lockfile is only safe when the corresponding manifest comes from the same side.

Regenerated with `pnpm install --lockfile-only`; `--frozen-lockfile` now passes
in `mobile/`. Verified the xterm patch entry survives intact — same hash
`4f1b42d268f3964d…` and the parent-relative path into `config/patches/`, which
is the desktop/mobile coupling that would silently break the mobile build.

Two earlier diagnoses of this failure were wrong and are worth recording: it was
not the root lockfile, and it was not a stale merge ref (a rerun reproduced it
exactly).

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-07 22:58:40 -07:00
757b785e43 fix(deps): resolve Dependabot security alerts across root and mobile (#13113)
Clears 47 of 49 open Dependabot alerts across the root and mobile lockfiles.
The 2 remaining (image-size) have no patched upstream release.

Direct bumps: pdfjs-dist 5.7.284 -> 6.2.108 (CVE-2026-16633), mermaid
11.16.0 -> 11.16.1 (root + mobile), dompurify 3.4.12 -> 3.4.13.

In-range re-resolves: brace-expansion, fast-uri, hono, ip-address,
js-yaml 4.3.1/3.15.1, nanoid, postcss, tar, undici 6.28.0/7.29.0.

Drops the @modelcontextprotocol/sdk>@hono/node-server override by bumping
shadcn's transitive SDK to 1.30.0, which widens its range to
^1.19.9 || ^2.0.5 so @hono/node-server resolves to a patched 2.1.0 on its
own. The other two overrides must stay: monaco-editor hard-pins dompurify
3.2.7 and xcode wants uuid ^7.0.3, both vulnerable.

pdf.js 6 removed PDFDocumentProxy.destroy(); PdfViewer now tears the
document down via the loading task it was already destroying.

Supersedes #13074, #13090, #12960, #12952.

Co-authored-by: mondaychen <monday.chen@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-07 19:56:26 -07:00
Brennan Benson 38ba22ecd1 fix(browser): align cookie import safeguards (#12607)
* fix(browser): align cookie import safeguards

* fix(browser): preserve sessions on failed cookie imports

* fix(browser): bound single-label cookie replacement

* fix(browser): preserve host-only parent cookies

* fix(build): bundle cookie scope parser
2026-08-05 12:51:05 -07:00
a7ed5a45c2 fix(mobile): render Mermaid diagrams in MobileMarkdown (#11185)
* fix(mobile): render Mermaid diagrams in MobileMarkdown (#11141)

Co-Authored-By: Grok Companion <noreply@x.ai>

* fix(mobile): keep streaming mermaid fences as raw code until the fence closes

* perf(mobile): memoize MermaidDiagram and add a CDN load watchdog

* fix(mobile): escape mermaid source before embedding in WebView script

JSON.stringify leaves </script>, &, and U+2028/U+2029 raw, so a diagram
source containing </script> broke out of the inline script and ran
arbitrary WebView JS. Diagram source is untrusted (agent output, PR/chat
content), and this component now renders from chat and markdown preview,
not just the PR sidebar. Escape those chars to \uXXXX; the literal still
parses back to the exact source. Adds an adversarial buildHtml test.

* fix(mobile): embed the mermaid engine instead of fetching it from a CDN

The diagram WebView loaded mermaid from jsdelivr at runtime: offline and
constrained-network renders always fell back, the stalled-load watchdog
existed only to paper over that, and an unpinned floating-major CDN script
with no integrity check ran inside the WebView. Embed the lockfile-pinned
package's prebuilt bundle via a postinstall generator (same mechanism as
the terminal WebView engine) so the document loads nothing external; the
watchdog is removed as obsolete and a no-external-URL gate pins it.

* chore(deps): align mermaid at 11.16.0 across desktop and mobile

Desktop floated ^11.15.0 while the mobile embedded engine resolved 11.16.0.
Raise the desktop floor so both lockfiles resolve the same version, and pin
mobile exact: the generated WebView engine embeds the package bytes, so an
implicit range bump would silently change what ships.

* fix(mobile): block Mermaid diagram network requests

Mermaid image-node URLs can initiate subresource requests even with the engine embedded. Keep the WebView offline by restricting resource types through its document CSP.

* style(mobile): format Mermaid routing test

* fix(mobile): use stable keys for Mermaid diagrams

* fix(mobile): keep duplicate Mermaid keys distinct

Combine each diagram source with its sibling occurrence so identical diagrams remain unique while source edits still remount the WebView and later streaming prose does not.

* fix(mobile): keep Mermaid transitive within release-age policy

---------

Co-authored-by: Grok Companion <noreply@x.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 20:05:16 -07:00
Brennan Benson 49dc113a0f Fix terminal corruption after restored snapshot replay (#12363)
* fix(terminal): preserve restored snapshot fidelity

* test(terminal): align legacy history handoff snapshot expectation

* fix(terminal): keep legacy snapshot panes mounted

* fix(terminal): refresh snapshot capability after startup

* fix(terminal): refresh snapshot capability in degraded startup

* fix(terminal): await snapshot provider authority
2026-08-03 20:00:28 -07:00
f82d4100b7 fix(terminal): flush Hangul syllables before the next composition (#12278)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Hyunggyun Lyou <hg.lyou@miraeasset.com>
2026-08-03 02:45:08 -07:00
Him188andClaude Fable 5 8e9640cb1d fix(terminal): stop cancelled IME compositions replaying stale preedit
Backspacing away an entire Pinyin preedit ended the composition with
empty data, no textarea residue, and no input/keypress events — yet
_sendPendingComposition fell back to the last non-empty
compositionupdate data and typed its first character into the PTY.
Only trust that fallback when observed input evidence corroborates it;
a composition with no evidence in any channel was cancelled.

Fixes the macOS Pinyin regression from #11293 (stray letter left after
deleting a preedit); same fix covers IBus/fcitx Backspace cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 01:53:02 -07:00
NeilandOrca 006ce9d116 fix(dev): split the confirmation dialog so Fast Refresh can accept it (#11980)
* fix(dev): split the confirmation dialog so Fast Refresh can accept it

`confirmation-dialog.tsx` exported both `ConfirmationDialogProvider` and
`useConfirmationDialog`, so React Fast Refresh could never treat it as a
boundary and Vite applied every edit to it in two passes under two `?t=`
stamps. When a second file in the same subtree changed in one watcher batch,
`createContext` ran twice and the provider published one context object while
the consumer read the other — `useContext` returned null and the hook threw.

Two field crash reports hit this at `ChecksPanel`, both dev-server sessions.

The context and hook move to a new component-free `confirmation-dialog-context.ts`;
`confirmation-dialog.tsx` keeps the provider and now exports only a component, so
the refresh runtime accepts it. Not one line of the provider body changes — the 16
hook importers just point at the new module, `vi.mock` targets included, and
`App.tsx` is untouched.

* test(dev): pin the confirmation dialog Fast Refresh boundary

The split that fixed the context-identity crash had no test behind it: no
test imported ConfirmationDialogProvider, and the six vi.mock call sites
replace the hook module wholesale, so they pass just as well with the
provider and hook back in one file. Assert the module shapes the refresh
transform actually keys on -- the context module registers no component,
so it never gets an HMR footer to invalidate through.

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

* test(dev): assert the refresh boundary on the module namespace

The source-regex guard did not guard. Its patterns match only declaration
forms, so `export { useConfirmationDialog } from './confirmation-dialog-context'`
in the provider module -- which restores the crash, verified in a browser --
passed it 3/3. It also failed on a comment that merely contained the word
createContext, and would fail on React 19's `<Ctx value={...}>` shorthand.

Assert on the module namespace object instead, using react-refresh's own
component criterion, so re-exports and default exports are visible. The third
test renders the provider and resolves the hook through it, which is a real
behavioural check rather than a shape one.

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

* test(dev): classify boundary exports with the refresh runtime's own predicate

The hand-rolled `^[A-Z]` name check called `export class Foo {}` a component;
the runtime rejects any class whose prototype carries extra members, so that
shape restored the two-pass split undetected. Use react-refresh's exported
`isLikelyComponentType` and mirror `isCompoundComponent` instead of a third
approximation. react-refresh was already resolvable only via shamefully-hoist,
so it is now an explicit devDependency.

* test(dev): tighten confirmation dialog boundary guard

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-02 01:02:11 -07:00
Brennan Benson 5e00a30e4e Decouple feature copy from locale parity (#8512)
* Decouple feature copy from locale parity

* Fix undeclared dynamic localization key check

* Fix localization code owner
2026-07-29 17:44:41 -07:00
fe6f929c6e fix(terminal): reconcile cross-platform IME composition lifecycle (#11293)
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: JeongUk Park <jeongph.dev@gmail.com>
2026-07-29 16:12:20 -07:00
Neil 038fd7a50c feat(workspaces): derive readable emoji identifiers 2026-07-27 22:11:56 -07:00
Neil 12ef12c55b chore(quality): ratchet Oxlint, React Doctor, and Zustand performance (#11034)
* chore(quality): ratchet lint and Zustand performance

* fix(ci): stabilize React peer lock snapshot

* fix(ci): isolate PR diff and React Doctor CLI
2026-07-27 18:58:36 -07:00
Neil 0f91af821d ci: parallelize PR checks and accelerate Vite builds (#10989)
* ci: parallelize and accelerate PR checks

* fix(ci): make accelerated checks runtime-safe

* fix(ci): address review findings

* fix(ci): retry transient Electron downloads

* test(ci): cover Electron download retry limits
2026-07-27 13:32:29 -07:00
Neil 2b244fa0ea fix(terminal): clear search highlights when the find bar closes (#10872)
Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

xterm's DecorationService keys its SortedList on `decoration.marker.line`,
but `SortedList.delete()` only records an index and defers compaction,
while `Marker.dispose()` sets `line = -1` — mutating that same sort key.
After the first disposal the array is no longer sorted, so the binary
search inside `delete()` can miss a decoration that is present. It returns
false, `onDecorationRemoved` never fires, and the decoration stays live and
keeps painting. Repaints don't help; they faithfully re-paint a live
decoration, which is why only a window cycle appeared to fix it.

`clearDecorations()` disposes the active match before the match
highlights, which is exactly the order that trips this.

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.
2026-07-27 00:10:04 -07:00
Neil a0944cc129 fix(linux): restore Ubuntu 20.04 launch — pin node-pty glibc symbols + add glibc/libstdc++ packaging gate (#9902) (#10019)
* fix(linux): restore Ubuntu 20.04 launch by pinning node-pty glibc symbols (#9902)

The bundled node-pty pty.node is compiled from source in release CI on
ubuntu-latest (glibc 2.39). glibc's 2.32-2.34 libpthread/libutil merge
relocated openpty/forkpty (GLIBC_2.34) and pthread_sigmask (GLIBC_2.32)
into libc under new symbol versions, so the from-source build bound to
versions absent on Ubuntu 20.04 (glibc 2.31). The main process imports
node-pty at startup, so the app crashed on launch. pty.node is the sole
blocker (Electron needs GLIBC_2.25; other native modules <= 2.17).

- Patch node-pty: a .symver shim pins the 3 symbols to their pre-merge
  version (GLIBC_2.2.5 x64 / GLIBC_2.17 arm64), and Linux-only ldflags
  force libutil.so.1/libpthread.so.0 back into DT_NEEDED. Guarded to
  Linux; macOS/Windows untouched.
- Add a packaging gate (verify-linux-glibc-floor.cjs, afterPack): reads
  each bundled native binary's objdump -p version needs and fails the
  Linux build if any strong GLIBC_/GLIBCXX_/CXXABI_ node exceeds stock
  Ubuntu 20.04 (glibc 2.31 / GLIBCXX_3.4.28 / CXXABI_1.3.12). Catches
  GLIBC_ABI_DT_RELR, rejects GLIBC_PRIVATE, skips weak needs, fail-closed.
- Docs + tests; the lazy sherpa-onnx speech prebuilt (GLIBCXX_3.4.29,
  never loaded at launch) is a documented libstdc++-floor exemption.

* fix(linux): assert DT_NEEDED provider deps in the glibc-floor gate

Harden the packaging gate (flagged in adversarial re-eval): the version-floor
check alone can false-pass if the patch's forced `-l:libutil.so.1` ever silently
drops — the pinned openpty@GLIBC_2.2.5 still resolves from libc's compat alias at
build time, but fails to load on Ubuntu 20.04 where openpty/forkpty live only in
libutil. The gate now also asserts that any binary importing openpty/forkpty
keeps libutil.so.1 in DT_NEEDED. Validated on a real symver-pinned .so with
libutil dropped (now fails) vs. present (passes). Documents the recommended
real-host smoke-test follow-up.
2026-07-22 19:11:44 -07:00
Neil 1d2cd33c83 fix(deps): resolve Dependabot security alerts (#10006) 2026-07-22 16:07:51 -07:00
Jinwoo Hong 808299cd1f fix(cli): avoid Windows PATH status timeout (#9483) 2026-07-19 21:59:22 -04:00
Jinwoo HongandOrca 360b2423ea Fix duplicate and lost IME terminal commits (#9235)
Co-authored-by: Orca <help@stably.ai>
2026-07-17 18:29:44 -07:00
Brennan Benson 7c0b84f2b6 fix(terminal): invalidate cached glyphs when WebGL atlas changes (#8899)
* feat(terminal): add flag-gated render-desync sentinel for WebGL panes

Detects the buffer-clean/render-stale glyph garble class in the field: per
visible WebGL pane, compare the cells the xterm buffer says hold glyphs
against the ink actually present on the canvas, sampled in the same task as
a forced synchronous redraw so a divergence proves the render model/atlas is
wrong rather than a missed present. A trip requires the same screen cells to
stay divergent across three samples (real desync is pinned; scroll lag moves),
then records a webgl-render-desync breadcrumb, stashes evidence (canvas PNG +
buffer text) for bug reports, and runs the same shared-atlas recovery a tab
reveal performs, so a stuck-garbled pane self-heals within seconds.

Off by default; arm on any build via
localStorage.setItem('orca:render-desync-sentinel', '1') and reload.

* fix(terminal): invalidate glyph cache on atlas replacement

Reproduce the WebGL atlas identity mismatch with two live terminals and force cached geometry to rebuild whenever a different shared atlas is attached. Persist flag-gated render-desync evidence and retain the investigation tooling used to validate the field signature.

* fix(terminal): harden render desync diagnostics

* docs(reliability): clarify Linux WebGL evidence gap
2026-07-17 16:16:17 -07:00
Brennan Benson ed2135d248 Preserve original markdown style when editing in the rich editor (#8862)
* Preserve original markdown style when editing in the rich editor

The rich markdown editor re-serialized the whole document into a canonical
style on the first edit, so a one-character change rewrote every non-canonical
construct (`_x_`->`*x*`, `__x__`->`**x**`, `* item`->`- item`, dropped trailing
newline) and produced a large, unexpected diff.

Reconcile the canonical getMarkdown() output back toward the original source
bytes at every disk-bound serialize site (debounced autosave, flush, Cmd/Ctrl+S)
so untouched regions keep their markup and only the edited region changes. A
divergent-base fuzzy patch (diff-match-patch) carries the user's edit onto the
original style; a safety re-parse requires the result to render-equal what the
editor shows, else it falls back to today's canonical output — so content
semantics can never change. The rich editor stays the default for markdown.

Fixes #6080

* Bound markdown reconciliation diff work
2026-07-15 13:33:11 -07:00
Brennan Benson 59a7fffcd6 fix(terminal): keep WebGL glyph atlas pages within the shader sampler budget (#8672)
* fix(terminal): keep WebGL glyph atlas pages within the shader sampler budget

The fragment shader has sampler slots for maxAtlasPages (16 on most Macs)
and leaves outColor uninitialized for any higher page index, so glyphs
rasterized onto pages past the budget render as garbled pixels. Long
sessions grow past the budget via the merge fallback, and the previous
wipe fix re-activated those unbindable pages, so every atlas wipe
re-allocated glyphs onto them (post-wipe allocation prefers the last,
highest-index active page) and garbled whole panes mid-stream.

Fix, matching the direction xterm.js maintainers are pursuing upstream
(xtermjs/xterm.js#6043): a shared _evictAllPages resets the atlas to one
fresh page, called from clearTexture and from the two allocation paths
that could otherwise push a page past the budget (merge fallback and
oversized-glyph page creation), so the page count can never exceed the
renderer's texture capacity. Defensive backstops: a one-time warn plus
bind-loop clamp, and an else branch in the generated shader so an
unexpected overflow renders blank instead of undefined pixels.

* test(terminal): cover WebGL atlas sampler budget

* fix(terminal): align WebGL atlas invalidation source
2026-07-14 14:48:21 -07:00