* fix: retain favicons across same-origin navigations
Move favicon clearing from did-start-loading to did-start-navigation and
only clear when origin changes. Chromium re-announces favicons only when
the icon URL list changes, so clearing on every load orphans same-origin
navigations. Extract favicon URL validation into a shared module.
* fix: drop favicon on cross-origin redirects
When a same-origin navigation redirects to a different origin, the favicon should be cleared to prevent stale icons from displaying the wrong site's identity.
* feat(native-chat): label Codex tool rows by what the command actually did
Codex's app-server `commandExecution` item carries `commandActions`, which
already classifies each command as a read, a search, or a directory listing
with the target path, name, or query extracted. Orca ignored the field, so
every shell call rendered as an undifferentiated row of raw argv.
Read it and name the row by its class, keeping the raw command and cwd for the
expanded view. Unclassified commands are untouched: absent, null, or malformed
`commandActions` produces byte-identical output to before.
Rank the search term above the command in the shared label keys so a classified
search row reads by what it looked for rather than the shell text that ran it.
No first-party tool input carries both keys today, so this only reaches the new
rows; an MCP tool supplying both would prefer its search term.
Note `commandActions` is the app-server spelling. `parsedCmd` is the rollout-file
shape and never arrives on this lane; a test pins that it stays ignored.
* feat(native-chat): give tool rows a category glyph beside their word
A row named only by a word makes the reader parse text to tell a read from
a search. Pair the word with an icon: icon for category, word for action,
argument for target.
Name the full eight-category vocabulary in `src/shared/native-chat-tool-icon.ts`
now — read/search/listFiles/unknown/fileChange/webSearch/mcpToolCall/
subAgentActivity — even though only the classified shell categories reach a row
today, so the MCP and web-search rows landing separately inherit these names
rather than coining their own. Glyph ids are the lucide spelling shared by
`lucide-react` and `lucide-react-native`, so mobile can resolve one name to its
own component when it adopts this; mobile rows stay text-only for now.
The glyph is decorative and `aria-hidden`: the word is the accessible name, and
never renders without it. One glyph per category, fixed across running,
completed, and failed — a row that swapped icons on completion would read as
changing identity — so the run header's active row also takes its category glyph
instead of the generic wrench it fell back to once these rows stopped being
called `shell`. A word outside the vocabulary gets the terminal glyph rather
than a blank slot, so rows stay left-aligned.
Also stand `.` in for a `listFiles` action whose `path` is null, which is what a
bare `ls` sends. The row named the action and then showed the raw argv as its
target; now it names the directory it listed.
* fix(native-chat): hold the tool run header's glyph fixed and size its slot to 16/14
The header swapped its leading glyph on settle: the active tool's icon while
running, a check once done. That is the identity swap a fixed per-category glyph
exists to prevent — the row appeared to become a different thing when it
finished. Name the header by the run's latest tool in both states and move the
completion check to the trailing edge, where the rest of the state signal already
lives.
Size both header slots to the mock's 16px slot with a 14px glyph, matching the
tool rows beneath them and the subagent summary row landing separately. They were
24/16, so the icon columns sat 8px apart and broke the left alignment the icon
treatment depends on.
The fixity test walks running, completed, and failed and pins the leading glyph
of every row by lucide's own class name, so a swap shows up as a different name
rather than a still-present icon.
* fix(codex): stop a classified shell row from asserting facts the command doesn't support
Three claims the `commandActions` row model was making on its own:
- `listFiles` with a null path was given `path: '.'`. Codex sends null for a
recursive walk and for the repo root, and the invented path flows into
`createToolInputDisplay().filePath`, which mobile turns into a tappable
"open file" link onto a directory — an affordance that can only fail. The row
now keeps the raw command, which is what the label logic already falls back to.
- A command whose actions classify as two different things (`cat a.txt && ls src`)
was named after the first one, silently dropping the rest. Recognized actions
must now agree on one class; a repeat of one class keeps the class and only a
target every entry names.
- `read` lifted `name` into the journal payload, where no label ever reads it —
`path` always wins — so it was bounded weight carrying nothing.
* fix(native-chat): give an unmodelled tool row a generic glyph, not a terminal
The row-word vocabulary named seven words, and everything else fell through to
the terminal glyph — which reads as "a shell ran here" for rows where nothing
says one did. Codex's own `apply_patch` row, `Grep`/`Glob`/`Task`/`WebFetch`/
`TodoWrite`, and every `mcp__*` tool all rendered a terminal, leaving the
declared `mcpToolCall` and `subAgentActivity` categories unreachable.
- Split the vocabulary: `unknown` stays the shell command Codex could not
classify and keeps the terminal, while a new `other` carries the generic
wrench that unmodelled words now fall back to.
- Read the edit family from `EDIT_TOOL_NAMES` and the command tools from
`isCommandToolName` rather than restating either. Command tools resolve first:
`isEditToolName` counts `shell`/`exec` as possible patch carriers, and a shell
row is not an edit.
- Result rows get no category glyph. Their word is `translate(…, 'Result')`, so
keying a category off it resolved a different glyph per locale; an empty slot
keeps the rows aligned.
- The header and the row now resolve through `NativeChatToolIcon`, so one `Grep`
run can no longer show a wrench in the header and a terminal on its line. The
glyph map and the unused `category` prop go with the duplication.
* fix(native-chat): give the projected Diff row the file-change glyph
Every Codex fileChange item projects to a tool call named `Diff`, which the
edit set does not name — it names the tools that carry the edit in their own
input. So a run whose body renders an edited-file card was headed by the
generic wrench.
* fix(codex): stop a classified shell row offering a folder as a file to open
A listFiles action's path is a directory, and a search action's path is the
root it scanned. Lifted under `path`, both became the row's file target, which
mobile renders as a tappable open-file link that can only fail — the same dead
link the removed `{ path: '.' }` stand-in would have produced. They lift to
`directory` instead, which still labels the row but is never a file target.
* fix(mobile): keep the terminal glyph on a classified Codex shell row
Mobile's run header picks between a terminal and a generic glyph by tool
name. Now that the host publishes `read`/`search`/`list` for the same
commands it used to publish as `shell`, that name check answers false and
a command that really ran heads its run with a wrench.
Ask the shared category vocabulary instead. Mobile keeps its two icons —
porting the full glyph set is a separate lane.
* fix(native-chat): say what the run header's glyph actually guarantees
The comment claimed the header names the same tool in both states, so its
glyph cannot change on settle. It can: the live header names the running
call while the settled one names the run's last tool call, and with
out-of-order completion those differ. The glyph is fixed for whichever
tool the header names — say that, and drop the never-taken running branch
from the settled header's call.
Also pin the other half of the file-target rule: `read` keeps `path`, so
its row stays tappable, where `list`/`search` lift a folder to
`directory` and offer no target at all.
* fix(native-chat): give a rollout-transcript shell row the terminal glyph
`exec` and `local_shell` are what the Codex rollout transcript names a
shell call — `native-chat-edit-normalize` already treats those three
words as the command tools — but the activity set the glyph vocabulary
reuses carries neither, so both rows headed a real command with the
generic-tool wrench.
Named in the vocabulary rather than in that activity set, because that
set also picks the running row's copy and this is only about the glyph.
* fix(mobile): pick the run-header glyph from the call's input, not its word
Codex now names a classified shell row `read` / `search` / `list`, which
lowercase to Claude's own `Read` / `Grep` / `Glob`. Mobile has only a terminal
and a wrench, so keying that choice on the row word gave Claude's filesystem
tools a terminal for a shell that never ran.
The input separates them: Codex keeps the raw command on a classified row,
while Claude's `Read` carries only a file path. `isShellActivityToolCall`
replaces `isShellActivityToolRow` and asks the command tool names first, then
the call's input.
* fix(native-chat): give the projected diff fixture its required digest
* fix(native-chat): head a settled run with a glyph the whole run shares
The settled run header drew the glyph of the run's last tool call while the
text beside it summarizes the run's first three, so a ten-call run ending in a
`read` showed an eye above "shell npm test · shell git status · …" — a category
the summary never described.
Resolve the header's glyph from every call in the run instead: the shared
category's glyph when all agree, the generic tool glyph when the run spans
categories, and no glyph when there are no tool calls. The running header still
names the active call, whose glyph is true of it.
---------
Co-authored-by: Merge Sim <sim@local>
* feat(perf): audit comparator setup and schedule performance contracts
* test(sqlite): close readers after expected busy failures
* ci(perf): trigger contract workflow on the contract files themselves
Without these paths a contract rename lands green on PR CI and only
breaks the next nightly, where nobody owns the failure. Also run the
OS-independent source audit once instead of on all three runners.
The Codex prompt rules in terminal-wait-detection scanned the whole retained
tail (up to 256 KiB) with lastIndexOf, so any quoted prompt phrase in
scrollback registered as a live prompt. A Codex agent working on Orca prints
rg hits from this very file; one such line ~300 lines above an idle input box
made `orca terminal send` refuse with agent_prompt_blocked and `terminal wait
--for tui-idle` report codex-interactive-prompt. `clear` did not help because
the detector reads the retained tail, not the visible screen.
A prompt that owns the terminal is at the screen bottom, so every blocked
rule now runs over the last 12 non-blank lines (real Codex dialogs are 4-8
lines), the way the cursor approval rule already was. The returned index is
offset back into full-tail coordinates so ready-header comparisons keep
working. The sentinel fast path is unchanged.
Line-window primitives move to terminal-wait-tail-window.ts to keep the
detector under the max-lines cap.
The incident monitor froze three healthy 15-minute production gates on
2026-09-05 because asia-east2 cells are judged against a bar calibrated
for us-central1. A cell's /ready fetches the auth JWKS and runs SELECT 1
against Cloud SQL, both in us-central1, so from the US GitHub runner the
asia-east2 round trip measures p50 0.88 s / max 2.7 s against 0.08-0.5 s
for us-central1 cells.
Give cell.<id>.latency_ms a per-region threshold (us-central1 2000,
asia-east2 4000) carried on IncidentCellExpectation from the tfvars
region. Director and auth latency rules keep the flat 2000 bar, and hard
faults are still caught by the .health/.ready equal-1 checks and the
probe's 8 s fetch timeout.
Also name the signal and its observed/threshold in the live preflight
failure message, keeping the source/code tokens other tooling matches on.
A first-hand Claude exit is not published where it is observed. `handleExit`
re-enters the close ladder and persists the transcript cursor before it emits
`ended`, and only that emission reaches the runtime's recovery chain. So the
runtime's `waitForRecovery` — whose whole job is to drain an in-flight recovery
before teardown stops children — returns immediately for an exit that is still
climbing the ladder, and nothing outside the adapter can tell an observed exit
from a published one.
The integration test for fenced host reconciliation had no handle on that
barrier, so it bounded-polled the lease for 100ms instead. Measured under 16x
local concurrency, publication alone takes 77-204ms: 19/24 runs failed.
Retain the ladder-then-settle tail on the exit record and expose
`drainObservedExits`, fold it into `waitForRecovery`, and export the barrier so
a caller that needs the settled lease can await it. Codex publishes inside its
own exit callback and needs nothing. The test now awaits the barrier: 0/24
under the same load, and it fails on an idle machine without the drain.
* fix: stop a handoff flow from outliving the host that owns its session
A structured handoff runs on the session's serialized chain and nothing in
production awaited it. When the client-side deadline for the switch expired
first, teardown dropped the session map out from under a live flow, and the
flow's own failure notification then threw `agent_session_ownership_unknown`
out of a status publish — an unhandled rejection, plus journal rows written
into a directory that was already being removed.
Three fixes, each with a regression test that fails without it:
- The status publish is a notification, not a mutation: it now reads the fence
without requiring an attached session, so an evicted or torn-down session
makes it a no-op instead of a throw.
- `track` used `.finally`, which forwards a rejection onto a promise nobody
awaits. `drain` settles flows through `allSettled`, so the bookkeeping chain
is now settle-only and cannot resurface one.
- Host teardown drains in-flight handoffs before dropping the session map.
`drain` existed for exactly this and was never wired up.
The integration test's `vi.waitFor` is dropped rather than widened: the request
enqueues the flow on the session's serialized chain before it returns, so the
status read is already ordered behind it. The poll only added a wall-clock
deadline that a loaded runner missed.
* fix: bound the handoff drain so a wedged flow cannot hold the quit open
Every Electron E2E spec that boots the app has been failing on
`workspaceSessionReady did not become true`, and the app itself has been
launching to a blank white window: the renderer threw
`ReferenceError: process is not defined` while evaluating a shared chunk,
so React never mounted and no startup step ever ran.
`agent-completion-poll-interval.ts` (renderer) imported one constant,
`PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS`, out of
`shared/process-table-snapshot-reader.ts` — a `node:child_process` /
`node:fs/promises` module whose dependency evaluates `process.platform` at
module scope to pick `ps` columns. The renderer runs sandboxed with
contextIsolation, where `process` is undefined, so that module-scope read
threw and took the whole chunk with it. Introduced by #18742; #18780 added a
second module-scope read next to the first.
The constant now lives in `shared/process-table-snapshot.ts`, the
environment-neutral half of the pair, and the reader re-exports it so host
callers are unchanged. The two `ps` column sets read the platform behind a
`typeof process` guard, which defuses the same landmine for any future
renderer import of that module — only hosts ever run the argv.
The regression test walks the renderer import graph (lazy routes included)
from all three entries and refuses any module that reaches a `node:` builtin.
It fails on the pre-fix import with the full 10-hop chain from `main.tsx`.
The same-cap wave validator approves 19 cells (c7-c26 plus the Asia cells
c27-c29), but the canary script it drives hard-rejected anything outside the
16 US capacity cells, so the first Asia same-cap canary failed closed at
isolate. Give the canary an explicit --approved-cells switch that selects the
same-cap allowlist, and pass it from the four same-cap job invocations. With no
switch the behaviour is unchanged, so the US-only capacity workflow keeps its
scope.
`worktree-base-divergence-real-git.test.ts` builds cap-sized histories (100 and
101 commits). Every `git commit` detaches `git maintenance run --auto`, whose
commit-graph task arms at 100 new commits, so the fixture reliably spawns a
background `git commit-graph write --split` that keeps creating
`.git/objects/info/commit-graphs` entries after the synchronous exec returns.
The `afterEach` recursive remove is then deleting `.git/objects` underneath a
live writer and dies with ENOTEMPTY — which is how "counts drift in both
directions" failed on main.
Reuse the existing `GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS` (it already
covers modern maintenance and legacy auto-gc, so it holds at the Git 2.25
baseline) in the fixture's git helper. Traced spawns of
`git commit-graph write` over a full run of this file: 4 before, 0 after.
The production path under test only runs `rev-list` and `merge-base`, neither of
which triggers auto-maintenance, so there is nothing to fix outside the fixture.
* fix(native-chat): say when a structured launch fell back to a terminal
A definitive refusal already opened a terminal instead of the requested
structured chat, but said nothing — indistinguishable from the bug where the
wrong surface opens. Notify at message severity, since nothing failed.
Also stop putting the raw error in the failure toast's description: it carried
errnos and absolute paths straight into the UI. The detail moves to a warn log
and the toast gets catalog copy, matching how the coded refusals already read.
* fix(native-chat): avoid overstating terminal fallback
---------
Co-authored-by: Merge Sim <sim@local>
#18796 made every SSH Codex background launch wait for the shell-ready marker,
but the client cannot see the remote shell. On a host that never publishes one --
fish, sh, Windows, or a relay predating #18796 -- no marker arrives and delivery
falls back at 1.5s where it used to write at 50ms.
The relay already computes whether it armed the marker; publish that as an
optional `shellReadyArmed` on the spawn reply and let the client skip a wait it
now knows is pointless. Absent stays UNKNOWN and keeps the client's own guess, so
an older host behaves exactly as before; false is only ever an answer a host gave.
It rides every reply, false included, or absent would stop meaning "old host".
A host that did not arm the marker did not arm bracketed paste either, so the
released path still submits raw.
* perf(terminal): cheap-tier process inspection for anchored local agent panes
Every idle local pane's completion cadence ran a full whole-host `ps` (with
`tty=` and `command=`, 0.34-0.50s on a 1,900-process Mac, 1.15s on Linux)
purely to build `foregroundProcessEvidence` that the renderer then discards
for local ids. Add a cheap tier (same job-control columns, no tty/command,
0.03s) gated so that it introduces no user-facing trade-off:
- Only a pane whose last FULL capture proved a recognized agent may take the
cheap tier. Panes with no anchor always take the full capture, so start
discovery keeps today's exact behaviour.
- The cheap tick compares a per-pane fingerprint (root shell pid+start, tpgid,
every descendant's pid+start+pgid+job-control state). Any change, a changed
node-pty foreground name, an unreadable capture, or an incarnation mismatch
escalates to the full capture. A recognized agent's exit is always a pid
vanishing, which the fingerprint always sees.
- A cheap answer OMITS evidence rather than fabricating a tty-less fence.
Remote/restore consumers never send `steadyState`, so they keep the full
capture unchanged.
- `steadyState` is a new optional request field; an old daemon ignores it and
answers with the full capture.
Measured (8 idle panes, 60s, idle cadence, forks counted by column set):
30 full -> 1 full + 29 cheap.
* fix(terminal): route the cheap ps capture through runProcess
The cheap-tier reader imported node:child_process directly, which the
child-process import-boundary and windowsHide ratchet tests reject (CI shards
1/8 and 3/8). Use Orca's single spawn entry point instead; it pins windowsHide
and encodes argv. Map its result onto the capture-error vocabulary:
outputTruncated -> capture_truncated, timedOut -> capture_timeout, non-zero
exit -> ps_exit_<code>. Tests mock at the runProcess seam.
* fix(perf): refuse a pane fingerprint when any descendant start marker is missing
`buildPaneProcessFingerprint` rejected only a missing root start marker; a missing descendant
marker was stamped as `?`. Two captures that both failed to read the same descendant therefore
compared equal, which removes the pid-reuse protection the fingerprint exists to provide: a
recycled pid could make a vanished agent look unchanged, and the cheap tier would keep serving
its name instead of escalating.
Reachable on Linux, where `readLinuxProcStartTime` legitimately returns null when a process
exits between the `ps` capture and the `/proc/<pid>/stat` read.
Every subtree member now needs a start marker or the fingerprint is refused, which sends the
caller to the full capture — the same conservative default every other uncertain path takes.
Reported by CodeRabbit on #18780. The two new tests fail against the previous code with
`expected '4242@2400#4300:|4300@?:4300:+' to be null`.
`readRelayDir` issued one `stat` per symlinked entry and awaited them all in a single
`Promise.all`. A pnpm `node_modules` is hundreds-to-thousands of package symlinks in one
directory, so expanding it over SSH put that many stats in flight at once, saturating libuv's
four-thread pool and delaying every other relay filesystem operation — including the
interactive reads `fs-list-files-scan-coordinator` exists to protect.
The probes now run through `forEachWithConcurrency` at 8, the cap every other bounded probe in
this codebase already uses (`GIT_COMMON_SNAPSHOT_CONCURRENCY`,
`PRUNABLE_EXISTENCE_PROBE_CONCURRENCY`, `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`).
Results and ordering are unchanged: every symlink still resolves to its target's kind, and
`sortDirEntries` still runs afterwards. The new test builds a 60-symlink directory and asserts
the same 60 stats happen with exactly 8 in flight at peak — the probes overlap, and never past the cap.
* fix(agent-session): refuse a pre-commit structured create with an envelope
The create route refused by throwing, which reaches a client as a generic
transport error indistinguishable from a lost answer — so desktop parked the
launch as visibility-unknown with no chat and no terminal. Convert the whole
pre-commit span, everything before `attach`, into a refusal envelope carrying a
code, and name the definitive-refusal allowlist the fallback decision needs.
* fix(agent-session): gate legacy fallback on definitive refusals
* fix(mobile): preserve unknown structured create outcomes
---------
Co-authored-by: Merge Sim <sim@local>
* perf(terminal): tighten the partial-escape-tail benchmark and equivalence test
* perf(terminal): spell the ESC gate the same way as the sibling ingest gates
* test(terminal): differential-fuzz the ESC-free partial-escape-tail gate against the unguarded fold
* test(terminal): make the escape-tail fuzz exhaustive at symbol depth, and cap the fold expectation
Two review findings on the differential fuzz, both about the test faithfully modelling the
function it guards.
The odometer generated strings by symbol depth but the caller filtered on `chunk.length`, which
is the UTF-16 code-unit count. An astral symbol is two code units, so every depth-4 string
containing one was silently skipped and the corpus was not exhaustive at depth 4 the way the
test name claimed. The generator now yields `{ depth, text }` and the caller filters on depth.
That restores the missing strings and takes the pinned corpus from 516,566 to 593,468 - exactly
the count CodeRabbit derived for the intended corpus.
The pairing assertion in the sibling suite compared the capped `advancePartialEscapeTail`
against an uncapped `extractPartialEscapeTail(pending + chunk)`. It passed only because no
pairing in that corpus crosses MAX_PARTIAL_ESCAPE_TAIL_LENGTH; it would have stopped modelling
the function the moment one did. The cap now lives in the expectation, matching the fuzz
oracle.
Re-verified the fuzz still fails on a wrong guard: mutating the gate to a bracket check fails
all four tests with a `gate diverged` assertion on a lone ESC chunk.
Reported by CodeRabbit and pullfrog on #18748.
Four renderer projections scanned a collection inside a loop over another collection, each on a
path that reruns per keystroke or per store write. All four now build the index once per stable
input, which is what the surrounding code already does for its other lookups.
`workspace-kanban-search.ts` called `searchWorktrees`, the convenience wrapper that builds the
palette document index inline. The board's filter hook memoized the whole call on the query, so
every character re-normalized and re-segmented every indexed field of every worktree — and did
it again on every agent-status tick while a query was active, since those churn board
identities. `buildWorkspaceBoardPaletteDocuments` splits out, memoized on
`[worktrees, repoMap]`; only the match reruns per keystroke. This is the shape
`worktree-jump-palette-document-index.ts` already provides for Cmd-J.
`useTabGroupItemProjections` resolved each editor tab against `state.openFiles` — the global
list across every worktree — and each `tabOrder` entry against the group's tabs, with a `.find`
per element. Both are now `Map` lookups, alongside the `terminalTabById` index the same file
already built. The memo key `groupTabs` gets a new identity on any unified-tab write, so this
ran on title, label and colour changes.
`buildSourceControlTree` rebuilt every ancestor path with `segments.slice(0, i + 1).join('/')`
per segment, making tree construction O(files x depth^2) in characters copied — on the path the
Source Control file filter rebuilds per keystroke. The path now accumulates.
`worktree-header-section-boundaries.ts` ran a full `findIndex` over the render rows for every
header row, plus an `indexOf` over the bucket ordering, in two `useMemo`s keyed on `renderRows`
— so it recomputed on every sidebar row-model change, not just during a drag. One indexing pass
each, first-match-wins to match `findIndex`/`indexOf`. The successor index is keyed per bucket
because a repo or group id can appear in more than one bucket ordering; a flat id-keyed map would
pick whichever bucket was iterated first. `worktree-header-section-boundaries.test.ts` pins that
and the first-wins duplicate-header case.
Measured by `pnpm bench:renderer-quadratic-scans`. Three scenarios time the production export
against a reproduction of the pre-change function; the tab-group scenario is modelled on both
sides because the projection lives inside a React hook. Each asserts before/after agree first.
| projection | drives | scale | before | after | |
| --- | --- | --- | --- | --- | --- |
| workspace board filter (per keystroke burst) | production | 300 worktrees x 12 keystrokes | 15.4 ms | 4.4 ms | 3.5x |
| tab-group projections (per unified-tab write) | modelled | 60 tabs x 120 open files | 1.23 ms | 0.34 ms | 3.6x |
| source-control tree build (per filter keystroke) | production | 5000 changed files | 6.2 ms | 3.9 ms | 1.6x |
| sidebar header boundaries (per row-model rebuild) | production | 80 repos x 600 rows | 2.0 ms | 0.8 ms | 2.6x |
The tree and sidebar wins are smaller than the scans they remove because the rest of each
function (tree finalize/sort, per-row size estimation) is linear and now dominates.
4,537 existing sidebar, tab-group and right-sidebar tests pass unmodified.
`advancePartialEscapeTail` runs once per PTY chunk, on the main thread, for every terminal —
visible, hidden or parked — inside `HeadlessEmulator`'s write path. It unconditionally
concatenated the pending tail with the whole chunk and then walked the result one code unit at
a time through a VT500 state machine.
`extractPartialEscapeTail` only leaves `ground` on an ESC byte, so with no pending tail and no
ESC in the chunk the answer is always ''. Taking that case up front skips both the full-chunk
concat and the walk. `String.prototype.includes` is a native scan, so the gate costs
essentially nothing on the chunks it does not short-circuit.
This is the same gate its two neighbours on the very same ingest path already apply —
`TerminalOscCwdTitleScanner.scan` and `TerminalMouseModeMirror.scan`, both carrying a comment
citing their measured share of a 2.2x ingest regression. This call was simply missed.
Measured by `pnpm bench:terminal-partial-escape-tail` over 640 x 16 KB chunks (10 MB), median
of 7 rounds:
| stream shape | before | after | |
| --- | --- | --- | --- |
| ESC-free (build logs, `cat`, piped output) | 40.3 ms | 0.19 ms | 213x |
| SGR-coloured output (gate does not apply) | 30.6 ms | 32.1 ms | 1.0x |
The benchmark proves equivalence over a 226-case corpus before timing, and a new unit test
pairs every pending-tail state the scanner can be left in against every chunk shape, asserting
the gate is indistinguishable from the unconditional fold.
The structured-session read owner emits per SDK event with no coalescing, so a working turn
re-renders `NativeChatMessageList` tens of times a second. `MessageRow` was a plain function
component, so every one of those frames re-ran `nativeChatProseToMarkdown`, an image-block
scan, and a provider-frame lookup for every message in the window — up to the 300-message
pagination limit — even though only the streaming tail had changed.
`MessageRow` moves to its own module and is memoized, and its four derivations fold into the
`useMemo` that already keyed on `message.blocks`. All of its props are primitives or
already-stable references (`onScrollMessageToTop` is a `useCallback`, `onLinkClick` comes
through `useShallow`), so the memo holds for every settled row. The move also takes
`NativeChatMessageList` back under the max-lines limit rather than bumping it.
`NativeChatToolRun` gets the same treatment: it was unmemoized and made four independent
passes over its block list per render.
`useNativeChatTurnStatus` allocated a fresh slice of the whole current turn and ran a nested
`.some()` over it on every render; both now memoize on `[messages, latestUserIndex]`.
Measured by the new `NativeChatMessageList.stream-render.perf.test.tsx`, a 120-message
transcript with a 20-frame streaming turn:
| markdown rebuilds per stream frame | before | after |
| --- | --- | --- |
| 120-message transcript | 120 | 1 |
The test counts real per-row work rather than a render counter, and it fails on the
pre-change component (`expected 120 to be less than 12`), so it is a genuine guard.
Pure memoization on unchanged props: no visual, ordering, scroll-anchoring, or lifecycle
change. All 997 native-chat tests pass unmodified.
* perf(terminals): let idle panes share one process-table capture instead of forking their own
Every visible local pane runs an agent-completion cadence that resolves through
`getStrictProcessTableSnapshot`, and the inspection queue already collapses every
shared-observation task enqueued in the same tick onto a single whole-host `ps`. Independent
±10% jitter per pane defeated that: the jitter was re-rolled on each reschedule, so panes
drifted permanently apart, each landing in its own tick and each missing the snapshot's 500 ms
TTL. Four idle panes cost four captures where one would have served all of them.
Idle panes now aim at a deadline grid anchored at the epoch. The pull-forward is clamped to the
snapshot TTL, so no interval is ever longer than its tier and none is more than 500 ms shorter:
a pane off the grid walks onto it over at most `tier / TTL` steps, costs at most one extra
inspection in total, and no inspection is ever delayed.
Scoped deliberately. A pane with a foreground agent, or one still inside the 10 s post-activity
hot window, keeps its exact interval and its own phase, so the bounded hot cadence is unchanged.
The error-backoff path keeps its jitter, where spreading retries across panes is the point.
Measured by `pnpm bench:agent-inspection-cadence` — whole-host `ps` captures over 60 s at the
2 s idle tier, median of 21 rounds:
| visible panes | before | after | reduction |
| --- | --- | --- | --- |
| 1 | 29 | 29 | 0% |
| 2 | 42 | 30 | 29% |
| 4 | 62 | 31 | 50% |
| 8 | 82 | 32 | 61% |
`process-table-snapshot-reader.ts` measures the `command=` column at 1.15 s of work for 1,948
processes, so these are captures a quiet app was paying for continuously.
All 4,202 existing terminal-pane tests pass unchanged, including the no-evidence cadence suite
that pins the relaxed and hot intervals.
* test(terminals): report n/a instead of dividing by a zero baseline in the cadence benchmark
A window shorter than one cadence tier leaves the baseline capture count at zero, and the
reduction line then divided by it and printed a meaningless percentage. Reported by CodeRabbit
on #18742.
* fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org
STA-6674: a Linux SSH host that cannot reach nodejs.org never came up. node-pty
ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp's default is
to download node-v<ver>-headers.tar.gz before configuring. The host refused
that connection (ECONNREFUSED) and the relay deploy failed inside npm install,
which the UI showed only as "Disconnected".
Every official Node build and every version manager that unpacks one already
has those exact headers at <prefix>/include/node. Export node-gyp's nodedir to
that prefix, on every command that can compile node-pty (npm install, npm
rebuild, the cloexec patch's rebuild), when the shipped node_version.h matches
the running Node. Both npm_config_nodedir (node-gyp 10, Node 20) and
npm_package_config_node_gyp_nodedir (node-gyp >= 11.4) are set so every Node
the relay runs on reads it. A version mismatch leaves it unset, which is the
existing behaviour.
When a host is both header-less and offline, name that in the deploy error
instead of forty lines of gyp http output, with the two remedies.
Reproduced and verified with a Docker sshd whose nodejs.org resolves to
127.0.0.1, on node:24.12.0 (the user's version), node:20 and node:26:
ssh-relay-offline-node-headers.docker.test.ts.
* fix(ssh): fail loudly when node-gyp ignores the exported Node headers dir
The headers export relies on npm forwarding npm_config_nodedir /
npm_package_config_node_gyp_nodedir into lifecycle scripts. If a future npm
drops that, node-gyp would silently fall back to downloading, and an offline
host would fail with the same "install an official Node" diagnosis -- wrong,
since the host did ship headers.
The prefix now echoes ORCA-NODE-HEADERS:<dir|none> into the command's output
before the compile, and the download-failure diagnosis reads it back: an
exported dir plus a download attempt is reported as an Orca defect naming
the dir, not as a host problem. Nothing else changes when it works.
* fix(ssh): address review on the relay node-headers export
- Unset any inherited npm_config_nodedir / npm_package_config_node_gyp_nodedir
before the conditional export, so a stale header dir from the remote profile
cannot bypass the version check and build a wrong-ABI binding (CodeRabbit).
- Require `gyp ERR! configure error` and a real network errno in the
headers-download matcher; node-gyp's fetch client logs retried attempts it
recovers from, and a FetchError can be a non-2xx mirror answer (pullfrog).
- Say "no local headers matching its own version", since the probe also
rejects a version mismatch, not only absent headers (CodeRabbit).
- Log the same diagnosis from the non-fatal `npm rebuild` fallback (CodeRabbit).
- Docker test waits for the SSH banner on the mapped port before connecting
instead of trusting `docker run -d` (CodeRabbit).
* fix(ssh): read the node-headers marker from the host output, not the quoted command
execCommand rejects with `Command "<command>" failed (exit N): <output>`, and
<command> quotes the whole prefix, marker echo included. The first-match
parser hit that copy and returned `${ORCA_NODE_HEADERS_DIR:-none}"; ...` as a
"dir", so every real no-headers failure was misreported as an Orca defect
(measured by an independent Docker exercise of 609685e). Strip the exec-
failure head before scanning; keep first-match so gyp output cannot spoof it.
The unit fixture hid this by rejecting with `Command "npm install" failed`,
a string production never builds. It now rejects from the command the mock
actually received, and the Docker test gains a no-headers failure case on the
same offline fixture that asserts the host-remedy message.
Also unset NPM_CONFIG_NODEDIR (npm accepts either case), and narrow the claim:
a ~/.npmrc nodedir= is not overridable from the env (measured: empty env
override is ignored on npm 10 and 11), so it stays the operator's setting.
Copy the node binary via fs in the unit test so a failed copy fails the test.
* docs(ssh): state the header-mismatch refusal as a conservative default, not an observed crash
* docs(ssh): note why the exec-failure head regex may match lazily