Commit Graph
8660 Commits
Author SHA1 Message Date
m4air 4057dd32f4 fix(crash-reporting): redact file:// URLs in crash reports
One lookbehind in the path redactor fails in opposite directions on the two
shapes a `file://` URL can take.

`PATH_PATTERNS` rule 5 is `(?<![A-Za-z0-9./])\/[A-Za-z0-9._-]+\/…`. Its
lookbehind rejects a slash preceded by a slash.

  - On POSIX a frame reads `file:///Users/alice/…`. The path's own leading slash
    sits behind the scheme's third slash, so the rule never fires and NOTHING is
    redacted — the account name and full filesystem layout ship in the report.

  - On Windows the same frame reads `file:///C:/Users/alice/…`. The drive letter
    interposes, the leading slash is preceded by `:`, and the rule fires.

So Windows file URLs were redacted and POSIX ones were not. Three payloads in a 326-file corpus carry a real account name: 25 occurrences,
13, and one with 48 path prefixes holding the name twice each — 96 literal
occurrences of a developer's account in a single submitted report, via
`/home/<name>/src/<name>/`.

Replaying those three payloads' actual leaking lines through both versions:
134 account-name occurrences before, 13 after. The 13 are payload C, whose
vector is a Vite dev-server URL this change does not address (below).

This adds two rules at the head of `PATH_PATTERNS`: a quoted `file://` URL, then
an unquoted one. Quoted first, so a URL holding spaces is taken whole rather
than truncated at the first one — that case previously leaked the entire path.

WHAT THIS DELIBERATELY DOES NOT DO

An earlier revision also preserved a stack frame's asset name and `:line:col`
while redacting its directory, because those offsets are the only axis a
minified React stack can be clustered on, and Windows loses all of them: across
17 minified React #185 payloads, darwin and linux kept 130 of 130 frames and
win32 kept 0 of 40.

That half is withdrawn. Gating on "ends in .js with a line and column" preserved
the basename of any user file with that shape. Gating instead on "carries a
bundler content hash" only narrowed it, because the shape it actually tests is
"the last 8 characters are not all lowercase letters" — which admits YYYYMMDD
dates, `_v2` suffixes, and every capitalised eight-letter dictionary word.
A grammar fuzz found 691,200 inputs where a user-identifying basename survived
the new code and did not survive the old, including
`tax-return-20241231.js` and `therapy-journal-20250101.js`. Against 5,000
ordinary basenames from this repo's node_modules, 99.3% become gate-passing once
one common date or version suffix is appended.

Two further defects went with it: running the frame pass first destroyed the
quoted-path rules' anchor, so a quoted region beginning with a frame fell
through to the unquoted rules and leaked from the first space onward; and its
two lookaheads made the regex quadratic on adversarial input — 4.1s at 50k
chars, 64s at 200k, on the unclamped breadcrumb path in the main process. With
the pass removed, 100k chars of the worst shape cost 0.10ms.

Preserving build frames is worth doing and needs a gate that constrains the
whole basename rather than its last 8 characters. It is not worth rushing into
the code path that decides what leaves a user's machine.

COST, STATED PLAINLY — AND IT IS SMALLER THAN IT FIRST LOOKS

darwin and linux stacks lose the frame `:line:col` offsets they currently keep,
because they were only keeping them by not being redacted at all. Windows is
byte-identical — it already lost them.

What survives on every platform: all 62 frame lines of an 8000-char stack, the
complete ordered minified function-name sequence (`_i, Ac, zv, Ll, kt, …`), the
component-stack component names, and `boundary_id` / `surface`. The function-name
sequence is the primary fingerprint crash aggregators group on; `line:col` is
the build-specific, weaker key. So these reports do not become unclusterable.

And redaction shortens the output enough to escape the 4000-char stack cap that
was silently truncating these. Measured on five real payloads (4 darwin, 1
linux) that previously shipped at exactly 4003 chars — the truncation marker:
they now ship at 899 / 1428 / 1476 / 1476 / 1499 chars, untruncated, with the
minified function-name sequence identical to the input in all five. Frame counts
come out equal rather than higher (37/37, 56/56, 57/57), because every over-cap
stack in the corpus is already one of the old code's truncated artifacts, so its
pre-truncation frame count is unrecoverable. The headroom gain is real and
measured; a frame-count gain is plausible but not demonstrable from this corpus.

A follow-up worth doing, and measured: preserving the `:line:col` alone — with
no basename — would restore the offset axis on every platform with no leak
surface at all, since digits carry nothing. Of the 17 payloads whose error_stack
holds the minified #185 string, 13 still carry offsets, and clustering those on
the INNERMOST THREE frames gives exactly 2 shapes (8 and 5). Stated precisely
because it matters: the full ordered offset sequence gives 9 clusters, since
outer frames drift per React build and the app-bundle frame moves every release.
The innermost frames are the stable part.

RESIDUALS — ONE OF THEM NEW

  - A Vite dev-server frame `http://localhost:5173/@fs/Users/<name>/…` still
    ships the account name. Not the `?v=` query or the `.tsx` extension, as an
    earlier draft of this message claimed: nothing anchors inside an `http://`
    URL at all, because every separator in `/@fs/Users/` is preceded by an
    alphanumeric. One corpus payload leaks a name 13 times this way.
  - `~user/…`, `.../Users/…` and `webpack:///Users/…` are unhandled.
  - An UNQUOTED path containing a literal space still stops at the space. Worst
    case is a Windows profile folder from a domain display name, leaving surname
    and employer. Quoted `file://` URLs holding spaces are now taken whole,
    which is new.
  - NEW, and the one thing here that is not pre-existing: redaction happens
    BEFORE truncation, so collapsing a long `file://` URL to `[redacted-path]`
    makes room under the length cap for content that previously fell past it. In
    a constructed dev-mode stack mixing `file://` frames with `/@fs/` frames, the
    old output truncated away all ten `/@fs/` frames and the new one ships all
    ten. Net leakage falls in every case measured this way (37→10, 34→10, 30→10,
    20→10 name occurrences) — but that does not generalise: a constructed input
    whose old output was name-sparse and whose newly-reachable tail is name-dense
    can raise the count, worst observed 5→515. Nothing newly exposed is content
    the old code redacted; it is unredacted residual whose reachability
    increased. No corpus payload contains both shapes.
  - Diagnostic BUNDLES, as opposed to crash payloads, are not sanitized by this
    code at all: roughly 274-282 of 321 expose 210-212 distinct home-directory
    usernames across some 800,000 occurrences (the range is definition-sensitive). That is a separate pipeline, and roughly four orders of
    magnitude larger than the payload leak this commit fixes.

No test covered a `file://` URL, which is how this shipped. There are now 49 in
this file: a per-platform frame case for darwin, linux, win32 URL and win32
backslash; nine document-shaped fixtures, each carrying a real `:line:col`, kept
so a future attempt to preserve frames has to clear the counterexamples that
killed this one; and three pinning the properties review found load-bearing but
untested — the `i` flag (an uppercase scheme was previously redacted by nothing
at all), the rule's inability to cross a newline, and quote-adjacency, which is
what stops it stealing an opening quote the way the withdrawn revision did.

One change post-dates the final review round and carries a round less scrutiny
than the rest: the unquoted rule was `file:\/\/\/?(?:[A-Za-z]:)?[\\/]…`, which
required a separator where an authority can appear, so `file://localhost/…`,
`file://host/share/…` and `file://Users/…` escaped every rule in the list. A
test added for those found it. It now takes everything after the scheme, which
is strictly more redaction; the `+` keeps the bare token in prose intact, and a
replay of all 31,497 payload lines leaves the same 13 residual account-name
prefixes, all Vite.

Review: four rounds. Round 1 found the frame pass preserved any `.js` basename;
round 2 found the hash gate meant to fix that was really a "last 8 characters are
not all lowercase" gate, with 691,200 leaking fuzz cases, and that the pass also
broke the quoted rules' anchor and made the regex quadratic. Removing the pass
was round 2's own recommendation. Round 3 fuzzed the remainder against the
pre-commit baseline over 604,800 grammar cases and 272,571 realistic cases and
found zero regressions, confirmed the anchor break is structurally impossible for
these two rules, and measured the worst adversarial shape at 2.9ms per 200k
chars against the removed pass's 64 seconds.
2026-09-15 08:45:49 -07:00
Neil 22ce8d69a1 fix(lint): enable anti-slop/no-module-mocking (#20783)
The rule rejects `vi.mock` / `vi.doMock` / `vi.unstable_mockModule` and the
`jest` equivalents, on the argument that a test which rewrites the module graph
asserts against a stand-in the production code never sees. It is already off for
`**/*.test.{ts,tsx}`, `**/*.spec.{ts,tsx}`, `tests/**` and `**/__mocks__/**` via
the existing override in config/oxlint-anti-slop.json; that override is
unchanged here. What the rule actually catches is module mocking that has drifted
out of a spec and into a first-party `.ts` support module, where nothing marks it
as test-only.

73 violations at baseline, all of them in test-support code. 9 were relocated
back into spec files the override already exempts; the remaining 64 sit in 10
files that are test-only but do not match the override globs, and carry a
file-level disable naming the rule and the reason.

Relocated:
- terminal-hydration-store-test-bootstrap.ts: the sonner / sync-runtime-graph /
  pty-transport `vi.mock` calls moved into the two specs that import it
  (terminals-hydration-canonical-rows, terminals-hydration-canonical-pty-overlap).
  Vitest hoists `vi.mock` inside a test file, so registration is strictly earlier
  than the previous module-eval-time call; the bootstrap keeps only the preload
  API proxy. Both importers were updated.
- ipc-events-ssh-authority-test-fixtures.ts: the 6 direct-ssh `vi.doMock` calls
  moved into useIpcEvents-agent-status-ssh-authority.test.ts as a local
  `stubDirectSshModules()` helper, which also de-duplicates the three copies the
  spec already had inline. The fixture now returns the store state and coordinator
  doubles it builds, typed via the exported DirectSshReconnectCoordinatorDouble.

Suppressed, with justification (each is `/* oxlint-disable
anti-slop/no-module-mocking -- ... */`, rule named, no blanket disable):
- config/scripts/headless-serve-shutdown-matrix.test.mjs (1) - a genuine Vitest
  spec that the override misses only because its globs say {ts,tsx}. The script
  under test is a top-level CLI module; the alternative is spawning real docker.
- src/main/codex-accounts/runtime-home-service-test-harness.ts (1) - stubs one
  probe predicate in ../pty/shell-startup-env, imported directly by several
  main-process readers; 17 specs share it.
- src/main/computer/desktop-script-provider-test-harness.ts (2) - stubs
  child_process/fs-promises for a provider that shells out; 8 specs share it.
- src/main/github/work-item-search-test-harness.ts (4) - one consumer lives in
  tests/e2e, where the relative mock ids resolve differently, so moving the calls
  into the specs would silently stop mocking there.
- src/renderer/src/components/automations/automations-page-test-harness.tsx (14)
  - the mount rig for 10 AutomationsPage specs.
- src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts
  (1) - stubs refreshWebRuntimeSessionTabsSnapshot, imported directly by several
  renderer runtime modules; 18 specs share it.
- src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts (7) -
  stubReactSyncEffect/stubAuxiliaryModules, shared by 11 specs.
- src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts (11) - stubs
  and hook invocation are one unit; 4 specs share it.
- src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts (13) - its
  only spec is at 799 of an 800 max-lines budget.
- src/renderer/src/hooks/ipc-events-test-harness.ts (10) - shared by 8 specs.

No violation was converted to real dependency injection, and no max-lines disable
was added.

Verified: the audit command exits 0 with no output (and reports errors on a
planted probe, so the rule is live); node config/scripts/run-typecheck-projects-in-parallel.mjs
exits 0; 354 spec files / 2506 tests covering every importer of every touched
file pass. No mobile/ file was touched.

The changed-code quality gate's root Oxlint scan runs without --config so it never
loads the anti-slop JS plugin, which made all 10 of those file-level suppressions
read as "Unused oxlint-disable directive". check-changed-code-quality.mjs now
exempts directives naming an anti-slop rule from that unused-directive warning,
the same carve-out isCastingDirectiveUnusedWarning already makes for the casting
suppressions the casting config enforces. Such a directive can never suppress a
root-config rule, so nothing the root scan would otherwise report is hidden;
audit:anti-slop remains the scan that enforces the rule.
2026-09-15 00:41:17 -07:00
Jinjing 775a932651 fix(git): distinguish binary absence from missing cwd on spawn ENOENT (#20798)
* fix(repos): preserve unknown Git availability

* fix(git): distinguish binary absence from missing cwd on spawn ENOENT

Node reports ENOENT for both a missing git binary and a missing working directory
during spawn. The fix checks specifically for spawn syscall, then verifies the cwd
exists to disambiguate. This prevents reporting "no Git" when the error is actually
a missing working directory. Centralizes probe logic in a reusable function; other
failures cause rejection so callers preserve the unknown status instead of collapsing
to false.
2026-09-15 00:22:15 -07:00
Neil 49e5fa597a refactor(lint): enable anti-slop/no-reflect-apply (#20782)
`anti-slop/no-reflect-apply` rejects `Reflect.apply(fn, thisArg, argsArray)`.
It defeats the call-signature checks TypeScript applies to an ordinary call:
the args array is checked as an array, not positionally against the callee's
parameters, so arity and type errors pass silently. Dynamic dispatch belongs
behind a named interface, not behind a reflective call.

Flipped the rule from "off" to "error" and cleared all 17 baseline violations
across `src config tests mobile` (16 sites; one file had two).

Fix pattern: `Reflect.apply(fn, recv, args)` becomes `fn.call(recv, ...args)`,
or a direct method call when the implicit receiver is already the right object.
The receiver is preserved at every site.

Where the callee is a captured built-in whose overloads split on an argument's
shape (`String.prototype.split`, `JSON.stringify`), a call-signature capture no
longer compiles once the args are passed positionally. Those three sites capture
the function through a method-shaped type
(`{ split(separator: unknown, limit?: number): string[] }['split']`), which keeps
the forwarding call checked rather than asserted.

Behaviour notes:
- `diff-section-layout.test.ts` drops a `limit === undefined ? [sep] : [sep, limit]`
  conditional. Equivalent: `String.prototype.split` maps an undefined limit to
  2^32-1, and the `Symbol.split` path forwards undefined either way.
- `workspace-space-compaction.test.ts` forwards `reduce`'s two arguments unchanged,
  so the `arguments.length >= 2` initial-value branch is unaffected.
- `agent-session-history-byte-accounting.test.ts` is the one site where the receiver
  is not literally preserved (`JSON` -> undefined). `JSON.stringify` never reads
  `this` per spec, and restoring `.call(JSON, ...)` would reintroduce the overload
  failure under strictBindCallApply.

No suppression comments added — the rule has zero `oxlint-disable` sites.

`Reflect.apply` still appears at electron.vite.config.ts:159, inside a template
literal of generated bootstrap source. That is string content, not lintable code.
2026-09-15 00:10:11 -07:00
Neil c9ae17fe3d fix(lint): enable anti-slop/no-unknown-type-aliases (#20784)
Flips anti-slop/no-unknown-type-aliases from "off" to "error" and fixes the
3 baseline violations.

The rule rejects a named type alias whose resolved type is `unknown` (directly,
through another alias, through parentheses, or as a member of a union). Such an
alias is strictly worse than writing `unknown`: it reads like a real domain type
at every use site while accepting anything, so the compiler stops helping and
readers are actively misled. `unknown` is fine, but it must stay visible at the
boundary that actually parses it.

Violations fixed (3 at baseline, 5 source files touched):

- src/main/runtime/workspace-session-failed-write-rollback.ts
  `type RollbackValue = unknown` -> a real recursive JSON-shaped union
  `RollbackSlot` (primitives | null | undefined | typeof MISSING |
  readonly RollbackSlot[] | RollbackRecord), with a named
  `type RollbackRecord = { readonly [key: string]: RollbackSlot }`.
  The record is a named alias rather than an inline index signature because
  inline violates typescript/consistent-indexed-object-style, `interface`
  violates consistent-type-definitions, and `Readonly<Record<..>>` trips
  TS2456 circular-reference. The named alias satisfies all three.

- src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts
  `type DirectSshReconnectTimer = unknown` -> `ReturnType<typeof setTimeout>`,
  the handle that actually flows. `DirectSshReconnectTargetState.timer` is
  widened to `DirectSshReconnectTimer | null` to match the state machine, which
  initializes to null and resets to null in the scheduled callback.

- src/renderer/src/hooks/direct-ssh-host-hydration.ts
  `type HostReadTimer = unknown` -> `ReturnType<typeof setTimeout>`.

Fix pattern throughout: replace the alias with the type that already flows
through the code, never with `any` and never with a relabelled `unknown`.
Because the timer aliases are now honest, two pre-existing
`as ReturnType<typeof setTimeout>` casts at the clearTimeout boundaries could be
deleted, a net win under the repo's type-assertion policy.

Suppressions added: none. No eslint-disable, oxlint-disable, `any`, or `as`
cast was introduced anywhere in this change.

The diff is type-annotation-only; no runtime statement changed.
2026-09-15 00:02:04 -07:00
Jinjing 3ec6193e0f fix(pty): preserve child-process inspection uncertainty (#20756)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.

* fix(pty): handle retired masters and pane instance swaps

Detect when node-pty retires the master fd (fd == -1) and return
unverifiable instead of misreading the spawn file as an idle shell.
Guard inspectProcess against PTY replacement mid-read to avoid pairing
old foreground with replacement's children.

* fix test

* fix tests
2026-09-14 23:53:05 -07:00
Brennan Benson ab6b86dd5c fix(orchestration): require registered structured worker pane key (#20664) 2026-09-14 23:00:35 -07:00
Neil 4a5b0583b2 fix(runtime): keep listed handles when graph sync learns a PTY incarnation (#20779)
reconcilePtyIncarnationHandles compared a null retained incarnation against the learned one and staled the handle. Daemon-hosted PTYs are recorded from first output before the spawn commit reports an incarnation, so on Windows `orca terminal create` returned a handle that was stale by the next graph publish. Treat null-to-known as un-fenced like every other site; keep the known-to-different and preallocated-handle invalidations.
2026-09-14 22:43:38 -07:00
Neil ef39f32d4f test(native-chat): split the windowing test harness out of the suite (#20773)
#20719 grew NativeChatMessageList.windowing.test.tsx to 897 effective lines,
past the 800 ceiling for test files, so oxlint fails on main.

Moves the shared layout/ResizeObserver stubs into
native-chat-windowing-test-harness.tsx. No test was changed, split or dropped:
still 5 describes and 23 it() blocks, 29 assertions passing. The stubs' mutable
knobs become one exported `layout` object because an imported binding cannot be
reassigned across modules.

AGENTS.md forbids a max-lines disable, so extraction is the fix.
2026-09-14 21:35:48 -07:00
Jinjing 99062ed80b fix(worktrees): preserve unverifiable disk witness (#20713)
* fix(worktrees): preserve unverifiable disk witness

* fix(worktrees): follow gitdir/commondir markers in disk witness

The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes.

Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors.

* fix: validate gitdir marker targets are directories

When a .git marker points to a missing or non-directory path, that's
unverifiable—not the same as an absent .git file (bare repo). Validate
accessibility before reading commondir to catch these errors clearly.
2026-09-14 21:03:51 -07:00
Brennan Benson 438603f9e7 feat(native-chat): add a message rail for jumping between your prompts (#20719)
* feat(native-chat): add a message rail for jumping between your prompts

A vertical rail down the right edge of the transcript, one bar per user
message, with the bar for the turn you are reading highlighted once
scrolling settles. Hovering the rail opens a panel that previews every
prompt and jumps to it on click.

Bars are capped at 20 and sampled evenly across the thread, always
keeping both ends and the active bar, so the rail stays readable at a
glance on a long conversation.

The active bar is resolved from virtualizer offsets rather than by
scanning rendered rows: the transcript is windowed, so an off-window row
has no element to measure. The row at the scroll fold resolves to its
owning prompt through turnKey, which is what keeps your own message lit
while you read a long reply instead of going dark.

Jumps reuse the existing reveal/pin path and scrollMessageToTop, which
releases the bottom pin. Scrolling through the virtualizer directly would
leave a reader snapped back down by the next streamed token.

Ticks cover loaded history only; older prompts gain a bar once "Load
earlier messages" pages them in.

* fix(native-chat): service a rail jump once and give its pin back

The rail borrowed the diff reveal's pin to reach a row the window had left
behind, but copied only its state shape, not its consumption. The request
was never cleared and the effect depended on `slots`, which is rebuilt on
every render, so three things went wrong at once:

- every later render re-scrolled to the jumped message, dragging a reader
  back there for the rest of the pane's life, and forcing the bottom pin
  off each time;
- the standing request outranked `revealedDiff` in the shared pin, so
  revealing a diff outside the window silently stopped mounting its row;
- the pinned row stayed mounted and measured indefinitely.

The request now carries a monotonic id, is serviced once, and is released
as soon as the scroll is issued, which hands the pin back.

The rail's scroll listener had the same churn: it listed `items` in its
deps, so a streaming turn tore the listener down and cancelled the pending
idle timer on every frame and the highlight never settled. It now
subscribes once and re-reads on a key built from the prompt ids.

Also: the hover trigger is a real button, because `asChild` discards the
primitive's focusable trigger and the panel is the only way to reach these
messages; the wheel forwarder honours line and page delta modes rather
than treating every delta as pixels; and the e2e panel assertion is exact,
since a loose bound passed at 20 rows against 20 ticks.

* fix(native-chat): make prompt rail accessible and reuse previews

* fix(native-chat): supersede prior navigation when selecting a prompt
2026-09-14 18:31:40 -07:00
Brennan Benson ff5b1a5a05 fix(native-chat): preserve detached transcript position during growth (#20710)
* fix(native-chat): stop the transcript following an end it measured short

The virtualizer compensates a row's measured size change by moving scrollTop
whenever it believes the view was already at the end. It decides that from the
spacer's own height minus a container-absolute offset, so the distance it
computes is short by everything in the document outside the spacer: the
transcript's top gutter, the "load earlier" block while older history is still
pageable, and the trailing chrome. A reader sitting ~100px above the bottom
therefore measured as "at the end", and every row that settled below them
dragged them down to it.

Measured in the windowing harness with a 92px gutter and 24px of trailing
chrome: a reader parked 96px above the end is pulled to the end on the first
growth frame, scrollTop 9261 to 9357.

The same option gates following an append, but that path measures the true
document distance, so it was never wrong, only redundant. The transcript
already decides whether to follow the end from the scroll container's real
geometry, and it re-pins once the growth is in the document rather than before
it, where the library's own write is clamped. Both library end behaviours are
retired by a threshold no finite distance can meet; the prepend anchoring that
shares the option is kept.

overflow-anchor:none is restated as structural: the engine's anchoring writes
never pass through the scrollToFn adapter that attributes this pane's own
scrolls, so they would arrive unmarked and read as the reader leaving.

* fix(native-chat): preserve visible rows on first measurement
2026-09-14 18:24:04 -07:00
Neil b61a2347b9 feat(design-system): gate renderer UI with @shadcn/lint (#20731)
* feat(design-system): gate renderer UI with @shadcn/lint

Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.

- config/oxlint-design-system.json: no-restyle (layout allowed),
  no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
  run over added lines only. Measured at 10 findings across the last 60
  commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
  renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.

Fixes the three live bugs the linter found:

- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
  generated no CSS -- 12 editor/artifact/notebook panes fell through to the
  page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
  the remote file browser breadcrumbs showed the scrollbar they meant to
  hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
  defines; the styled class is `markdown-body`. They rendered unstyled.

* ci: run the dead-class gate in PR CI

`pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires
every `pnpm lint` step to have a matching step in pr.yml.

* fix(notebook): keep markdown theme selectors working
2026-09-14 17:52:21 -07:00
Brennan Benson db09a7bd50 fix(native-chat): let a reader park just above the latest message (#20709)
* fix(native-chat): let a reader park just above the latest message

A reader who scrolled up by less than the bottom threshold was still
classified as being at the end, so follow stayed armed and the next chunk
of stream carried them back down. One constant was answering two
different questions: how close to the end still counts as pinned, and
whether a reader's own scroll meant to stay there.

The first wants slack, because a streaming last message jitters in height
by tens of pixels. The second wants almost none, because it is a
statement of intent. Give it its own, far stricter band, and move the
choice of band into the decision rather than leaving it to the call site,
which is where the two got conflated.

Re-arming follow now requires the reader to be within 4px of the end:
enough for fractional-pixel and zoom rounding, well inside one line of
prose. The pin and the jump-to-latest affordance keep their 48px band.

* fix(native-chat): make transcript intent own end following
2026-09-14 17:47:19 -07:00
Jinjing ffc331212c Fix PTY child process verdict to preserve unverifiable state (#20729)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.
2026-09-14 17:13:25 -07:00
Jinjing 4bcdc67369 Distinguish pane load failures from empty states (#20735)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(settings): tell a failed load apart from a genuinely empty pane

* refactor: consolidate import types and simplify failure handling

- Move filesystem import types to shared for renderer use
- Add compactIpcErrorMessage for single-line error display
- Consolidate entry failure toasts to single global slot
- Simplify account tracking and discard retry logic

* fix type

* fix: clear stale state when pane loads fail

Credential reads, account fetches, and skill scans can fail, leaving stale
data on screen. This change clears previous state when a load fails,
distinguishing load failures from genuinely empty results, and prevents
stale controls from appearing after failed re-checks.

Use readIpcErrorMessage for consistent error handling and track runtime
targets to invalidate results from old targets.

* fix(settings): show credential action when bitbucket status read fails

When the credential-read operation fails, allow users to retry by showing
"Add or replace credentials" button. Initialize the credentials dialog with
the current (confirmed) connection state instead of stale data from a failed
read, preventing outdated information from pre-populating the form.
2026-09-14 16:44:30 -07:00
6cb5643241 fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376)
* chore(deps): evaluate coordinated Tiptap security migration

* fix(editor): adapt link ranking and initialization for Tiptap 3.31

* fix(editor): preserve literal Markdown through Tiptap serialization

* test(editor): cover literal saves in local folder and paired workspaces

* test(editor): reselect folder after closing its final tab

* perf(editor): avoid repeated inline source-marker lookahead scans

* refactor(editor): inline redundant HTML match wrapper

* test(chat): await Tiptap React skill-pill rendering

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-14 16:36:06 -07:00
Jinjing b8554f1c59 fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
2026-09-14 15:22:05 -07:00
Neil 767b7c14f1 fix(ai-vault): expand nested OMP session history (#20663)
Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629.

61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed.

Addresses #12885 Scope 2.
2026-09-14 15:17:36 -07:00
Neil 742a7ad842 fix(omp): resume independent child sessions from history (#20629)
Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available.

Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim.

Addresses #12885 Scope 1.
2026-09-14 15:05:36 -07:00
Brennan BensonandMerge Sim f55b7ba680 fix(native-chat): cancel pending prompts precisely (#20601)
* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

* fix(native-chat): cancel pending prompts precisely

* fix(native-chat): consolidate capability imports

* fix(native-chat): harden precise prompt cancellation

* fix claude cancellation teardown races

* retry claude prompt lifecycle admission

* bound claude prompt cancellation retry work

* fix(codex): bound prompt turn identity on registration

* fix(native-chat): route rejected late dispatch settlements

* fix(codex): retain exact cancellable prompt turn ids

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 14:59:03 -07:00
Neil dd85e5fc81 fix: keep OMP terminals when folder workspaces become Git repos (#20653)
Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout.

Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed.
2026-09-14 14:54:53 -07:00
Neil 41e42beab4 fix(worktrees): safely remove prunable git-file registrations (#20617)
Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion.

Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery.

Fixes #17316
2026-09-14 14:52:45 -07:00
Neil bac96b212e fix(hooks): actually terminate a timed-out hook's process tree (#20576)
Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes.

Refs #19334
2026-09-14 14:52:35 -07:00
Brennan Benson 4a027626e9 fix(agent-session): honour the backup-recovery fence floor on surface release (#20708)
* Fix surface release fence recovery floor

* fix(agents): advance backup recovery floor past lost mint
2026-09-14 14:46:24 -07:00
Neil 49fba59925 fix(runtime): apply the tui-idle evidence ranking to mailbox delivery (#20578)
fix(runtime): retry a delivery that the idle gate refused

Gates delivery at the two points where each implementation commits to typing into
the pane, rather than at each caller, and parks-and-re-offers a refusal so late
idle evidence cannot strand a queued message.

Refs #6011
2026-09-14 14:45:54 -07:00
Wooseong KimandWooseong Kim 46eb5959fa fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554)
An idle agent pane kept ~40% of a core busy just by being frontmost. The
xterm cursor and the native chat caret blink with no paint-containment
boundary, so Chromium treated each blink as damage to the whole pane
ancestry and re-rasterized it twice a second.

- `.xterm-container` and the native composer's input shell get
  `contain: paint`, bounding blink damage to the surface that blinks.
- The mention hint gains `z-20` to match the slash picker: a contained
  element becomes a stacking context and paints at z-index 0 in tree
  order, which would otherwise cover the hint's drop shadow.

Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins
over the option in `_updateCursorBlink` — so parking `cursorBlink` does not
reliably stop a hidden pane blinking. Pre-existing, documented only.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
2026-09-14 14:27:31 -07:00
NeilandBrennan Benson 389d672dab fix(omp): preserve saved conversation names in session history (#20636)
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-09-14 14:17:04 -07:00
mmarabelandNeil 68f0b2e835 feat(runtime): stream file uploads instead of buffering whole files (#16106)
* feat(runtime): stream file uploads instead of buffering whole files

Staging read each dropped file whole with readFile(), base64-encoded it
(a 4/3 expansion), and passed the string through IPC to the renderer,
which re-chunked it. Peak memory was ~2.3x the file size before a byte
moved, so a 25 MB per-file cap existed to protect the heap.

Staging now records identity only. The byte pump moves into main, where
the file handle and the runtime socket both live: 384 KiB slices (512 KiB
once base64-encoded, matching the chunk size the renderer used) appended
through the existing files.writeBase64Chunk RPC. Peak memory is one slice
regardless of file size, so the ceilings become user-safety limits on an
unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors
name both the size and the limit.

Because staging and streaming are separate calls, the staged entry carries
size, inode, device and mtime, and the streamer re-checks all four against
the pre-open lstat and against the handle it actually reads. A source
replaced or rewritten at the same size between the two calls is refused
rather than uploaded under the original name. The post-read check compares
mtime as well as size, so an in-place rewrite mid-transfer aborts before
commitUpload renames anything into place.

O_NOFOLLOW, realpath containment and stat identity are preserved, and the
pairing revision plus the runtime id ride every chunk, so a re-pair or a
replacement runtime aborts instead of appending the rest of the file to a
different host.

No wire change: files.writeBase64Chunk and its params are untouched, so
old and new hosts behave identically. The SSH import path is separate and
unchanged. The web client has no local filesystem to stream from and says
so instead of failing obscurely.

* fix(runtime): close the empty-upload and per-drop budget holes

Two gaps the first pass left open.

A zero-byte source returned before the post-transfer identity check, so a
file that gained content during the empty write's round trip committed as
an empty file at the user's chosen name. The empty chunk now falls through
to the same final check the slice loop uses.

Each staged source also started its own byte counter, so the 8 GB ceiling
capped one source rather than the drop: five 2 GB files staged cleanly at
10 GB total. The IPC handler now carries one budget across sourcePaths and
adds only what each source actually staged. The per-file ceiling is still
re-enforced where the bytes move; the drop total holds at staging because
identity enforcement means each file streams exactly the bytes measured.

* docs(runtime): name the invariants the upload helpers carry

* fix(runtime): name the source in errors and stop uploads with their window

Three problems an independent review turned up.

A dropped file's relative path is '', so the over-limit error read "'' is
3 GB, over the 2 GB per-file remote import limit" — the message this change
exists to fix, naming nothing. Errors now fall back to the file's own name;
the staged entry keeps '' so the destination path is unaffected. The
streamer had the same shape, falling back to the hidden .orca-upload-<nonce>
temp destination, a path the user never chose.

The byte loop used to live in the renderer and died with it. Moving it into
main meant closing or reloading the window left the rest of a multi-GB
transfer running, with the renderer's temp cleanup never reaching its
finally. An AbortSignal now rides the caller's lifetime and every chunk, is
re-checked per slice, and main sweeps the abandoned temp path itself when
the renderer is no longer there to do it.

Upload failures also reached the import result wrapped in Electron's
"Error invoking remote method '...'" prefix, because the throw crossed IPC
instead of happening in-renderer; extractIpcErrorMessage unwraps it.

An existing staging test asserted the empty-name message, so it encoded the
bug rather than catching it; it now asserts the file name.

* test(runtime): cover the containment check and the per-chunk host guards

The "escapes the dropped root" test only reached the lstat symlink guard,
so assertEntryInsideRoot had no coverage at all. The shape that actually
needs it is a regular file under a symlinked intermediate directory: lstat
sees a plain file, and realpath containment is the only thing that refuses
it. Disabling the guard now fails this test and nothing else.

Nothing asserted that the SSH target, connection generation and execution
host reach the writeBase64Chunk params either — the renderer tests stop at
the IPC boundary, so the streamer's half of that contract was untested.

* fix(runtime): survive a straggling append when sweeping an aborted upload

Aborting rejects the in-flight chunk locally, but the host may still apply
that append, and appends open with flag 'a' — which recreates the file the
sweep just deleted. The delete and the straggler also race: they are
separate calls on a queue that is not ordered between them.

Slices are strictly sequential, so at most one append can be outstanding.
A second pass after it has had time to land is therefore sufficient, not
merely a heuristic. The sweep moves out of filesystem-mutations.ts into its
own module so the behaviour is testable directly.

Found by an independent review pass, which also pointed out that the
"escapes the dropped root" test only reached the lstat symlink guard.

* fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk

did-start-navigation fires before will-navigate blocks an external link or a
stray file drop, and the renderer survives those (verified against Electron 43
with a hidden window). Aborting there killed a healthy upload with a misleading
'window went away' error. did-navigate fires only once a new document has
replaced the caller.

The renderer's per-chunk calls used to go through the IPC handler that refuses
a manually disconnected environment; the loop in main made no such check, so a
disconnect mid-upload kept pushing the rest of the file. The handler now
resolves the selector to an environment id and the streamer checks it per slice.

Adds slice-boundary coverage against the real chunk schema and host write
flags, staging-to-stream on a real filesystem, and handler-level lifetime tests.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-14 14:08:31 -07:00
Neil f21f81dcfc fix(agents): find OMP by its full project name (#20647)
* fix(agents): find OMP by its full project name

* test(agents): make picker baseline proof omit OMP aliases

* style(test): brace picker baseline condition
2026-09-14 14:03:11 -07:00
Neil 8d93505958 fix(terminal): retain renames before renderer pane hydration (#20619)
* fix(terminal): retain renames before renderer pane hydration

* test(terminal): keep late renames from recreating closed tabs
2026-09-14 13:57:59 -07:00
Neil ee1a0a4e2d fix(git): avoid Windows tree kills after the command has exited (#20606)
Validated and independently reviewed OMP integration fix.
2026-09-14 13:56:25 -07:00
Neil fc4519cda4 fix(omp): preserve zsh startup with global aliases (#20621)
Validated and independently reviewed OMP integration fix.
2026-09-14 13:56:22 -07:00
Neilandshahidbeig-a11y 3632311d0b fix(omp): preserve status after terminal title owner rewrite (#20610)
Validated and independently reviewed OMP integration fix.

Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com>
2026-09-14 13:56:18 -07:00
Brennan BensonandMerge Sim 955051ded0 fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138)
* fix(codex): settle a structured send on admission, and stop minting a colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.

* test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

* fix(codex): preserve unsettled dispatch correlations

* test(codex): type the dispatch fixtures instead of asserting over them

main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.

* fix(test): merge the duplicate doubt-reasons import the merge left behind

Both sides added an import from journal-dispatch-doubt-reasons and the
merge kept both statements, which the whole-repo native plugin gate
refuses under --deny-warnings.

* test(codex): a Fast mode turn is admitted, not accepted

#20506 landed its Fast mode tests against the dispatch contract this
branch replaces: a Codex send now returns admitted and settles its
identity on the provider echo. The tier assertions the test exists for
are untouched.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 13:37:13 -07:00
Neil dede24df46 fix(store): preserve state identity for no-op updater branches (#20703) 2026-09-14 13:33:19 -07:00
Neil c3372aeadc fix(ai-vault): bound streamed remote JSONL records (#20700) 2026-09-14 13:30:40 -07:00
2186a885dd fix(store): stop two no-op writes from re-running every selector in the app (#20641)
* fix(store): stop two no-op writes from re-running every selector in the app

zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that
mean "nothing changed" hand it a fresh reference instead:

- `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its
  file that did; its four siblings all early-return `state`.
- `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and
  `Object.assign({}, state, {})` reproduces every field unchanged while still
  notifying. ~20 sibling sites in the same store already use `return state`.

Both rebuild the root and wake every subscribed selector (~2.2k per the listener
census). Renders are unaffected — the selection is unchanged — so the cost is
wasted selector evaluation, not commit pressure. The resulting state looks
identical either way, which is why it goes unnoticed; both tests therefore count
subscriber notifications rather than asserting state.

* test(store): use checked initial state in notification regression

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-14 13:23:34 -07:00
Jinjing 6c70801f72 fix(source-control): prevent text wrapping in section headers and action buttons (#20046)
* fix(source-control): prevent text wrapping in section headers and action

Use flex layout constraints (flex-1, shrink-0) and text truncation instead of
wrapping to keep section labels and action buttons on a single line in the
right sidebar.

* test(source-control): add section action button alignment tests

Ensure View all button stays on single line with icon actions in
crowded section headers. Pin layout constraints (shrink-0, flex-wrap,
whitespace-nowrap) to prevent regression.

* Rely on Button base styles for action label wrapping

Remove redundant shrink-0 and whitespace-nowrap utilities from
section action buttons. These should be supplied by the Button
component's base variant, not duplicated at each usage site.
2026-09-14 13:07:27 -07:00
Jinjing d5be0d69e7 Add copy button to code blocks (#20357)
* feat(native-chat): add copy button to code blocks

Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop.

* i18n: add English copy code button label

* refactor: use React.isValidElement type parameters for type narrowing

- Specify props types as type parameters to React.isValidElement instead
  of casting after the fact
- Allows TypeScript to narrow element.props type automatically
- Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage
2026-09-14 12:54:31 -07:00
Jinjing 2acd2f4c88 Surface stage, unstage and discard failures with retry capability (#20423)
* fix(source-control): surface stage, unstage and discard failures

* fix(source-control): use single slot for entry failure toasts

- Consolidate entry failures to one stable slot instead of per-worktree
- Handle stale retries inline at click time rather than via a cleanup hook
- Remove retry button from discard failures to prevent destructive accidents

* test: improve type safety and mock patterns in source-control tests

- Add proper type definitions for toast options and test data instead of using `as never`
- Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern
- Create `entry()` helper to construct typed test entries
- Add explicit type annotations to mocked functions for better IDE support

* test: extract shared toast options type for source control tests

Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency.

* fix(source-control): separate refresh failures from mutation failures

Post-mutation refresh failures are logged separately, not surfaced as toasts
(mutation already succeeded). Use preventDefault() on retry to prevent sonner's
auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into
a shared handler to reduce duplication.

* fix(source-control): only dismiss entry failures from the owning worktre

Track which worktree owns the shared entry-failure toast slot. When a mutation
completes, only dismiss the slot if the completing worktree is the one that
raised the failure — a slow retry in one worktree should not erase a failure
another worktree has since raised into the slot.

* Remove entry mutation status refresh helper

Inlined into the caller during consolidation of failure handling and
tracking in the source-control entry mutations flow.

* Simplify entry mutation refresh without wrapper

Call refreshActiveGitStatusAfterMutation directly instead of through the
refreshEntryMutationStatus helper. This ensures refresh failures propagate
directly from the callback without being caught as mutation failures.
Remove tests that validated the wrapper's error handling.
2026-09-14 12:45:25 -07:00
Brennan Benson d8b6151e8c fix(native-chat): keep a resumed transcript pinned to its end (#20651)
* fix(native-chat): keep a resumed transcript pinned to its end

Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up.

Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader.

This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer.

* fix(native-chat): cancel stale end reconciliation

* fix(native-chat): attribute scroll ownership centrally
2026-09-14 12:22:44 -07:00
Brennan Benson b4d435806f fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654)
`AgentJournalSubmission.reason` was the only unbounded field written by
Orca's own code. `dispatchSafely` sets it from the adapter's raw
`error.message` and `journalDispatchRowBuilder` stored it verbatim, so a
provider error carrying a multi-megabyte body -- a stringified HTTP error
payload, say -- reached the row at whatever length the provider sent, and
stayed on disk at that size for the life of the journal.

It now goes through `boundInlineText` with the journal's existing inline
limit, the same idiom already applied to arbitrary text on the Claude and
Codex translation paths.

The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure`
prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on
it, so a bound that kept the tail instead would stop classifying a clipped
transport failure and render raw provider text to the user as an ordinary
rejection notice. A test pins that, and clipping stays marked rather than
silent so a truncated reason is never presented as the provider's complete
explanation.

Rows written before this keep their full text, so readers can still meet an
unbounded reason.
2026-09-14 12:14:07 -07:00
Brennan BensonandMerge Sim c6a7216984 fix(native-chat): hide activity while awaiting input (#20496)
* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 10:42:38 -07:00
Jinwoo Hong eba56f2f69 feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516)
* feat(ai-vault-search): persist agent-session search consent and retention

Two booleans and nothing else: `enabled` and `historyDays`, off by default
because building the index reads every transcript on the machine. No `paused` --
the PR 3 indexer is immutable, so every change is close-and-construct.

The settings IPC normalizes a write like every other field and hands the change
to the index; there is no UI for it until PR 8.

* feat(ai-vault-search): hold one indexer and engine pair per host

The object that owns a host's live index and the three recipes that change it.
The indexer is immutable, so a settings change is close-and-construct, disabling
is close with no replacement, and clearing is close, remove the database,
construct. The new instance's first sweep purges a narrowed window and admits a
widened one, so neither needs a code path.

The database sits beside the scanner's parse cache, one file per host. A runtime
with no node:sqlite can hold no index at all, which the Node 18 floor on orcad
and the relay makes a real case rather than a hypothetical one.

* feat(ai-vault): let the scanner child own the session search index

The transcript reader runs in that child, so the index consumer has to as well:
one read serves both the session list and the index. Three request operations
(search, status, reconcile) and one fire-and-forget settings message carry
everything a parent needs; main never opens the database file.

The init frame becomes a factory because it is read at every spawn, so a
respawned child sees current consent rather than the first frame's. A child
holding a running index is never idle from the parent's side, so idle retirement
is suppressed while the index is on -- retiring it would stop the reconcile loop
until some later scan happened to respawn one.

Both files this lands in were already at the max-lines ceiling, so three
collaborators move to where they belong rather than being disabled around: the
invalidation deadline into the class that owns invalidations, call cancellation
and the start requeue into the call-state module, and orcad's flag parsing into
its own file.

* feat(ai-vault-search): register a search service on every host that answers

Without a registered service a host answers no-service, which means "this host
does not have the feature" rather than "the index is off". All three hosts now
answer the second thing.

The desktop forwards to the scanner child. orcad and the SSH relay daemon have
no such child -- orcad ships only the watcher and daemon entries, and the relay's
AI Vault sidecar runs the remote scanner, which publishes nothing to the
transcript channel -- so on those two the index lives in the process that would
drive its reads, gated on a runtime that has node:sqlite at all.

The relay registers with consent off and no way to turn it on: nothing carries a
setting to a remote host yet. That is the honest state, and it is still worth
registering, because it is what tells a client the difference between off and
too old.

* test(ai-vault-search): price a warm pass over five thousand transcripts

The number the reconcile interval will be revisited against, measured rather
than argued: a warm sweep stats every file under every root, a warm cycle stats
the newest N per agent, and neither reads what the index already holds. It does
not tune the interval.

* fix(ai-vault-search): answer the casting gate without assertions

main's new type-assertion rule reaches every file this branch touches. All nine
sites drop the cast rather than carry a SAFETY: rationale: the operation guard
narrows with `in`, the sqlite probe narrows the builtin it loads, the child test
keeps the discriminated reply instead of widening it, and the settings resolver
takes `unknown` -- which is what it really reads, since a persisted profile can
hold a value no version of this code wrote.

* fix(ai-vault-search): let a refreshed scan root reach the live index

The parent re-resolves scan roots before every policy push, precisely so a
WSL distro or extra Codex home that appeared since the child spawned enters
the window. The child forwarded only the settings to a live instance and used
the roots solely in its `??=` initializer, so those roots were dropped for the
child's lifetime.

The indexer stays immutable: a structurally different root set closes the pair
and constructs a new one, the same way a changed databasePath already does.
Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because
nothing fixes the key order two producers write; lists are sorted too, since
the indexer walks every root and a re-enumeration that reorders is not a
change. An unchanged set still never restarts a running index.

The orcad and relay in-process hosts resolve roots once at install and never
re-apply, so they have no such seam.

* fix(ai-vault): restart the scanner child the index is holding

Three review items.

The hold keeps a child alive for the index, but only a queued call ever
started one: `pump()` skipped a hold with an empty queue, so an idle indexing
child that crashed, or an `ensureChild()` that failed at start, left indexing
stopped until an unrelated request happened to arrive. `pump()` now starts the
child the hold requires, which is also the restart callback the fault policy
already schedules, so the existing delay and circuit bound the retry exactly as
they bound a queued call's start. `updateSessionSearch` goes through the same
seam instead of its own `ensureChild` call.

A search registers no AbortController, so a cancel sent for a search id was
added to the `cancelled` set and never consumed. Nothing can reach that today
-- no caller passes a signal and the child answers in milliseconds -- so this
is only a leak of ids: consume it when the search settles.

The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser
takes the next token regardless, and orcad-launch-contract.test.ts pins that,
so the doc is what was wrong. Behaviour is unchanged.

* fix(ai-vault): recover search indexing and refresh scan roots

* fix(ai-vault): defer search refresh policy reads

* fix(session-search): stabilize paging and host enablement

* fix(session-search): refresh host roots within full sweeps

* docs(session-search): clarify initial root fallback
2026-09-14 13:38:37 -04:00
Brennan Benson a4c11f1889 fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581)
* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows

A structured chat pane could latch "Working for N" forever after the agent had
finished, showing the send arrow rather than Stop, while the sidebar and
`worktree ps` correctly read idle.

The client replica has one position (`state.cursor`) and one body. Two
operations keep those consistent: replace (both from one host snapshot) and
append (rows contiguous with the cursor). The `tail-page` branch was a third
thing: it took the cursor from the journal head, the items from a bounded page
(200 items, byte-capped), then merged retained client submissions over the
page's. Under continuous journal writes the client is always slightly behind,
so the branch ran on every window focus and on every pane re-activation. When
more than a page of rows had landed since a send, that send's user item fell
off the page, its submission was not carried, the retained `pending` survived,
and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a
batch carries only touched items and that submission is never touched again.

Delete the third operation rather than guard it. A live subscription is now the
only thing that moves the cursor, and `subscribe({ cursor })` already replays
exactly the missed rows.

- remove the window `focus` listener and the owner/transport `refresh` contract
- skip warm hydration: a retained owner subscribes at its applied cursor
- cold hydration keeps its history read, applied as the existing `snapshot`
  (replace) event rather than `tail-page`
- delete the `tail-page` action and its reducer branch
- delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors
  with two advancement rules were how position and body drifted apart

`older-page`/`loadOlder`, the unattached-refusal grace, generation guards and
the coalescer are unchanged. No host, wire or schema change.

Also fixes a second cost of the same branch: focus during a busy turn discarded
paged-in older items, shrinking the transcript to one bounded page mid-turn.

* fix(native-chat): preserve unavailable mixed-version session fences
2026-09-14 10:28:16 -07:00
Neil 8e26d516d8 perf(browser): dispatch coordinate pointer input in process instead of one subprocess per event (#20593) 2026-09-14 01:45:34 -07:00
Bjorn RunakerandNeil 01f8aa8d96 fix(grok): stop SessionStart orca-status hook hanging for 10s (#20090)
* fix(grok): stop SessionStart orca-status hook hanging for 10s

Grok writes one JSON hook payload and waits for the process to exit
without closing stdin. The POSIX hook used `cat`, which waits for EOF,
so SessionStart deadlocked until Grok's 10s timeout:

  session_start hook (global/orca-status) failed, ignored: timed out after 10000ms

Read one JSON object with raw_decode instead; return as soon as the
object is complete. Fall back to cat when Python is missing.

* fix(grok): decode the hook payload incrementally instead of per chunk

The JSON stdin reader strict-decoded the whole accumulated buffer after
every read and caught only json.JSONDecodeError. A multi-byte character
split across two os.read calls therefore raised UnicodeDecodeError, which
is a ValueError but not a JSONDecodeError, so the interpreter died — after
consuming stdin. The `python3 || python || cat` chain then handed the next
reader a truncated stream, and `cat` blocked on a pipe the caller never
closes, reinstating the exact 10s SessionStart timeout this reader exists
to avoid. Measured: a CJK+emoji payload written byte by byte hung until it
was killed; a 200KB payload split mid-character arrived as 4 bytes.

Hold the decoder across reads, treat any ValueError as "not complete yet",
and guard the whole program so a non-zero exit implies stdin was never
read — only then is the `||` fallback safe. Emit the object's own text
rather than re-serialising it, which was rewriting non-ASCII as \uXXXX.
Skip leading whitespace, which raw_decode does not. Separate the
first-byte wait (5s) from the idle wait (1.5s) so a writer that is merely
late is no longer dropped.

Also unset a HOME that does not exist before spawning the interpreter:
macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its
whole tool lookup without a reachable cache, costing 6.6s per spawn and
overrunning Grok's budget on its own. This is what made the existing
large-payload and empty-PATH lifecycle cases fail.

The Python program now lives in a shell variable instead of being inlined
twice, which halves the generated script and keeps it readable.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-14 01:45:00 -07:00
manuaudioandClaude Opus 5 170dbdb874 fix(ai-vault): ignore non-absolute env overrides for agent scan roots (#13118)
Six scan roots took a directory from an environment variable and used it
verbatim. A relative value is resolved by whichever Orca process reads it —
main sits at `/` when Finder-launched, the terminal daemon chdirs itself to
the user data dir, the AI Vault service inherits main's cwd — so one value
names a different directory in each, and walkSessionFiles walks it with no
depth cap, no entry cap and no time budget, about once a minute per the
session-list cache TTL.

The agent CLIs do accept a relative home (verified against real Grok 1.0.30:
`GROK_HOME=myhome grok du` creates `<grok-cwd>/myhome`), but they resolve it
against their own per-terminal cwd, which no Orca reader shares. Falling back
to the default home is therefore not a lost configuration — it replaces an
unbounded walk of an arbitrary tree with a bounded read of a known one, and
matches what readGrokHomeEnvelope, skill-provider normalizedRoot and
absoluteConfiguredDir already do with the same values.

Add resolveAbsoluteDirOverride and apply it to CODEX_HOME, COPILOT_HOME,
OPENCLAW_STATE_DIR, DEVIN_HOME, KIMI_CODE_HOME and GROK_HOME. It takes an
explicit platform so the Windows shapes are provable from a POSIX CI box:
`C:\...`, `C:/...` and UNC roots are kept, while the drive-relative `C:foo`
and bare `C:` fall back. Tilde expansion stays out of it — Grok creates a
literal `~` directory rather than expanding one — so absoluteConfiguredDir
keeps its own Pi/Prime-specific expansion and delegates the absolute check.

isAbsolute is syntactic only, so `/..` still collapses to `/`. That is fine
for read-only discovery; these roots never gate renderer-supplied paths.

Tests assert at the call sites, not just on the helper: the four
session-scanner-agent-sources roots are module-level consts evaluated at
import time, so they are exercised through AI_VAULT_AGENT_SOURCES with
vi.stubEnv plus vi.resetModules. Reverting any one of the six call sites
fails them (11-33 cases each).

Closes #13082

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 01:44:53 -07:00
2ed89b8781 fix(github): name an unfiltered empty project view instead of blaming a filter (#20588)
* fix(github): skip Projects search index for unfiltered views

Empty query still used items(query:\$q), which routes through GitHub's
Projects search index and can return totalCount 0 while the board is full
during index lag. Omit the query argument when the view filter is empty.

Fixes #12648.

* docs(github): drop the false stable-shape claim for empty project filters

Unfiltered item fetches omit items(query:) so boards skip search-index
lag. The View.filter field is still '' when GitHub returns null.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(github): name an unfiltered empty project view instead of blaming a filter

The search-index workaround in this branch was a no-op. Live introspection of
ProjectV2.items shows `query` is declared `String = ""`, so omitting the
argument and sending `$q = ""` coerce to the identical resolver input; GitHub
applies declared defaults for omitted args (verified against its own endpoint).
There is no non-search item field on ProjectV2 and ProjectV2View has no `items`
at all, so no request shape can dodge the index. Revert the branching query
construction and the module it added.

What the user actually reported in #12648 is the copy: a view with no filter
rendered "No items match this view's filter", which reads as data loss when a
freshly populated board momentarily comes back empty. Word the empty state from
the view's own filter — the filter message only when there is a filter, and an
honest "no items yet" plus a transience hint when there is not — and share the
one implementation between the table and roadmap surfaces.

Refs #12648.

---------

Co-authored-by: bbingz <zzb@gxsmjx.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 01:44:47 -07:00