Commit Graph
9075 Commits
Author SHA1 Message Date
Isaque Coelhoandm4air 853afdf80e fix(status-bar): remove pet menu reserved space (#13067)
* fix(status-bar): remove pet menu reserved space

* test(status-bar): add pet segment layout validation tests

- Unit test guards against pr-[6.5rem] padding reintroduction
- E2E test measures trailing overhang instead of total width delta
  for more accurate layout validation
- Extract enableExperimentalPet helper for test clarity

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-08-23 12:21:15 -07:00
github-actions[bot] e3327c2f31 Update README downloads badge 2026-08-23 18:27:41 +00:00
Jinjing e50cc309c3 fix(runtime): prevent restored workers from appearing idle while busy (#15998)
* fix(runtime): classify tui-idle from the visible screen only

The adopted-PTY tui-idle probe added in #15569 read the provider snapshot as
`scrollbackAnsi + data`, and the Codex readiness classifier matches the startup
banner. For a daemon-hosted adopted worker — where the retained tail stays empty
forever — every wait re-probed and could resolve `satisfied: true` off banner
history while Codex was actively working, turning a loud timeout into a silent
false ready.

- probe now requests and parses the visible grid, never scrollback
- retirement of a timed-out provider acquisition is checked before the
  re-acquire branch, so a wider row request can no longer resurrect a hung
  provider
- probe builds its result before clearing the poll interval, so a stale handle
  cannot leave the waiter with neither poll nor probe

Fixture follow-ups from the same review:
- resume legs pin the captured `launchConfig.agentCommand` to the fake instead
  of bare `codex`, which resolved the machine's real Codex off PATH
- the command override is quoted for the Windows shell the runtime will actually
  use, and specs pin that shell alongside the override
- fake agents acknowledge a bare submit after a short grace, so an unbracketed
  delivery path fails with a diagnosable ACK instead of a suite timeout

Refs STA-4907, STA-4885

* test: assert tui-idle probes serialize visible grid only

- Verify idle timeout probes exclude scrollback from serialization
- Add test case for Git Bash shell path quoting with apostrophes
- Simplify verbose test helper comments

* test: improve fake agent paste protocol validation

Refactor paste end detection to properly track both begin and end markers,
validate bracketed paste protocol (RFC 2544) through chronological event
sequencing, and emit correct error messages for protocol violations. This
ensures reliable detection of when pastes complete even when delivered
across multiple chunks, and correctly distinguishes between bracketed and
unbracketed paste modes.

* fix(runtime): reject provider snapshots when live output advances

Provider snapshots become stale when live output is received after the
snapshot is requested. Reject snapshots where the current output sequence
exceeds the snapshot sequence, preventing callers from consuming outdated
terminal state. Add tests verifying stale frame rejection.
2026-08-23 09:45:52 -07:00
Neil c3a1694b1d perf(preflight): read the WSL mount table once, and make launch agree with detection (#16053)
* perf(preflight): read the WSL mount table once per shell, not once per CLI

The prelude is embedded in the lookup script, and the caller wraps that in
`for cmd in <every agent>`, so the unconditional assignment forked awk once per
probed CLI -- 36 of them inside the distro against a 10s detection budget. The
comment claimed it was read once outside the loop; it was not.

`${x+set}` rather than `[ -n ... ]`: a host with no Windows mounts yields the
empty string, which must still count as read.

Pinned by counting real awk forks through /bin/sh with a stub on PATH, because
nothing covered this expression at all -- a wrong-field mutation shipped green.
Verified to bind: the unconditional form counts 4 for 4 commands.

* fix(wsl): make launch resolve the same binary detection reported

Agent detection skips Windows mounts during the PATH walk; the Codex WSL
command builder and the WSL branch of isCommandOnPath did not. So Orca could
report the guest codex as installed and then launch the Windows one sitting
ahead of it on PATH, or disagree with itself between preflight and detection
about the same distro.

Both now pass the same option.

Verified on a real Windows host against a real WSL2 distro, with a Windows
binary planted ahead of a guest one on PATH:

  plain `command -v orcaprobe` -> /mnt/c/Users/neil/orca-agree/orcaprobe
  this lookup                  -> /home/neil/.orca-agree/bin/orcaprobe

That host reports /mnt/c as 9p, which the mount expression matches, so the
fstype list is confirmed against hardware rather than fixtures.

* test(preflight): prove the memoised mount list applies past the first command

Counting awk forks with a stub that reports no mounts cannot see what the
hoist trades correctness for. A mutant that empties `_orca_win_mounts` inside
the walk keeps the fork count at 1 and keeps every existing test green, while
every agent after the first stops skipping /mnt.

This runs two commands behind a stubbed Windows mount and asserts both resolve
to the guest binary. Verified against that exact mutant.

Credit: review counsel.
2026-08-23 02:58:40 -07:00
NeilandMelih b2902cb61e fix(agent-resume): restore Kimi Code sessions after restart (#15883)
Co-authored-by: Melih <mberatsanli@gmail.com>
2026-08-23 02:26:52 -07:00
OrcaWinandOrcaWin 4c984d4c1b Fix Windows Git Bash console-capacity failures (#16045)
* fix(terminal): retain failed local console panes

* fix(terminal): preserve failed pane restart context

* fix(terminal): scope capacity recovery to PTY binding

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-23 01:24:37 -07:00
Neil e26f849682 fix(wsl): budget the whole command line, not just the script (#16032)
* fix(wsl): budget the whole command line, not just the script

The argv/stdin threshold measured `script.length`, but the cap applies to the
finished command line -- which also carries `PATH=<login PATH>` and `HOME=`.
A login PATH is itself a few KB.

That produced a perverse band: with a long enough PATH, a 7,999-char hook was
placed on argv and CreateProcess refused it, while the SAME hook at 8,001 chars
flipped to stdin and ran. Size decided how a hook behaved, in the wrong
direction, and the failure looked like "your setup hook failed" with nothing
pointing at length.

Now the argv form is built, measured, and only used if the whole line fits;
otherwise the script goes to stdin as before. The count over-estimates slightly
(it charges quoting for every argument) because over-counting is the safe
direction for a cap.

The regression test uses a 7,000-char script -- deliberately under any
script-only threshold -- with a 27KB PATH, and asserts it lands on stdin. My
first attempt used 7,999 + `echo `, which is 8,004 and flipped under the old
rule too, so it passed either way and proved nothing.

Credit: Grok.

* fix(wsl): charge quoting and measure the line that is actually spawned

Two under-counts the review found in the estimator I added.

The doc comment claimed it over-counts. It did not: libuv escapes every `"`
and doubles a backslash run before a quote, so a quote-dense script costs more
than its length. And `wsl.exe` plus `-d <distro> --exec` are prepended AFTER
the measurement, so ~45 characters of the budget were never counted.

Together those put a quote-heavy ~26KB script on argv and over the real 32767
ceiling -- where the old script-only rule would have sent it to stdin and it
would have run. A narrower band than the one this PR removes, but the same
shape of bug, so worth closing before merge rather than after.

Now charges one character per `"` or backslash and measures the full spawn
line. New test: 26,000 quote characters must land on stdin; verified to fail
with the quoting charge removed.
2026-08-23 00:57:34 -07:00
Neil 92315c4178 fix(preflight): do not count a Windows binary reached through interop as a WSL install (#16028)
* fix(preflight): do not count a Windows binary reached through interop as a WSL install

WSL appends the Windows PATH to the guest PATH by default, so on a distro with
no guest `claude`, `command -v claude` resolves to
`/mnt/c/Users/me/.../claude.exe`. That path is POSIX-absolute, so the existing
absolute-path check accepted it and preflight reported the agent as installed
in the distro.

That is worse than reporting it absent. Absent tells the user to install it; a
false positive launches a Windows executable inside a Linux session, where it
sees Windows paths, no guest $HOME and none of the distro's config -- and the
failure surfaces later, somewhere less obvious.

Rejects `/mnt/<drive>/` and any `.exe`, case-insensitively. A genuine guest
install is unaffected.

* fix(preflight): skip Windows mounts during the PATH walk, not after it

The review caught this and it is the more important half of the fix.

Rejecting the interop path in TypeScript happens after the guest walk has
already stopped on it: the lookup breaks at the first executable, and the
version-manager fallback dirs are APPENDED, so they sit behind the Windows
entries WSL appends. A user with claude in nvm AND on the Windows PATH
therefore went from a false positive to "not installed" -- the exact #9725
population the fallback dirs exist to serve. Worse than the bug being fixed.

The lookup now takes `skipWindowsMountDirs` and skips those PATH components
mid-walk, so the guest binary behind the shadow is still found. Matched by
mount metadata from /proc/mounts (drvfs/9p/virtiofs), not by a `/mnt` name:
the automount root is configurable, and `/mnt` is an ordinary directory on a
Linux box. That also closes the custom-root hole the reviewers found in the
name-based predicate.

The TypeScript check stays as a secondary net for a mount the guest does not
report, with a comment saying why it must never be the thing that decides.

Proven with a real /bin/sh: a Windows `claude` ahead of an nvm `claude` on
PATH now resolves to the nvm one.

Credit: review counsel, and community PR #12794 (spfcraze), which proposed
this shape first.

* fix(preflight): let the mount table be the only word on what is a Windows path

The name-based check could veto a path the walk had deliberately kept. /mnt/d
is a perfectly ordinary Linux mount, so a guest binary there was resolved
correctly by the walk and then discarded by its name -- the #9725 false
negative, reintroduced by the belt-and-braces net I added "just in case". And
if awk were missing, the name rule became the only rule, which is precisely
the failure it was supposed to backstop.

The walk skips components the guest itself reports as drvfs/9p/virtiofs. That
is authoritative. Without a mount table we now degrade to main's behaviour (the
old false positive) rather than inventing a new false negative.

Net: one predicate, three fixtures and an import deleted.
2026-08-23 00:57:05 -07:00
Neil 0e9f02baf3 fix(settings): an empty agent detection must not erase the saved default (#16043)
Three bugs in one screen, reported in #15256 with a diff of the user's
orca-data.json showing defaultTuiAgent going from "claude" to null.

1. The Auto pill's handler writes null, and it was rendered as the ACTIVE
   choice whenever the stored agent was merely not detected right now. So the
   pill that already looked selected was destructive: one click erased the
   setting, and a later successful detection did not bring it back. Auto is now
   active only when null is actually stored. Detection is a transient fact; the
   stored value is not, and this control reports the stored value.

2. With zero agents detected there were no agent pills at all, so the stored
   choice was both invisible and unrecoverable -- nothing to click to put it
   back. The stored agent is now always offered, labelled as saved but not
   currently detected.

3. Refresh lived inside the Installed section, which only renders when at least
   one agent was found, so the only retry control vanished in exactly the state
   that needs it. An empty result now renders its own Refresh.

This matters more now than when it was filed: #16028 makes WSL detection
legitimately return an empty set on machines where the only agent was a Windows
binary reached through interop, so the empty path is about to get more traffic.

Each of the three tests was verified to fail with its own fix reverted.
2026-08-23 00:56:36 -07:00
Neil 7a739c6bf5 fix(secrets): probe the Linux-only storage backend defensively (#16046)
`safeStorage.getSelectedStorageBackend` is `@platform linux`, so it is genuinely
undefined on macOS and Windows — confirmed against the installed Electron 43,
where it reads `undefined` on darwin and `function` on Linux. The shipped code
called it behind a `process.platform === 'linux'` check, so it never threw, but
the guard was the only thing standing between that call and a startup TypeError.

The platform check now lives with the probe, alongside a typeof check and a
try/catch, and an unreadable or unknown backend reports no gap — claiming one we
cannot prove would be its own kind of lie.

The gap this closes is in the tests, not just the code: every suite here mocks
safeStorage with the method present, so the suite could stay green while the
shipped app threw. The new case deletes the member from the live mock rather than
re-mocking, because the module already holds that object and a later vi.doMock is
inert — the first version of this test passed against the unguarded code, which
is the failure mode it exists to catch. Verified against the expression currently
on main: three cases fail.
2026-08-23 00:38:28 -07:00
Neil 1375c57b16 fix(secrets): report the protection gap on change, not on every launch (#16044)
The gap warning fired every startup with no way to stop it. It usually needs a
keyring installed and unlocked to fix, so repeating it every launch is nagging
the user cannot act on and will learn to ignore.

It now reports when the answer changes: once when the gap starts being true,
again if it becomes true for a different reason, and once when it is fixed —
because silence after a "your secrets are not protected" warning would leave the
user assuming that is still the case.

State lives beside the profile data file, which is why the call moved out of the
port bootstrap: that state has nowhere to live until the profile exists. A
corrupt state file re-reports rather than trusting it, and a failed write logs
instead of failing startup, since re-reporting next launch is the safe direction.

ORCA_ALWAYS_REPORT_SECRET_PROTECTION=1 forces a re-report for support without
disturbing the stored state.
2026-08-22 23:56:01 -07:00
Neilandhwantage 202d74a8a4 fix(git): enable Windows long paths for worktree creation (local, sparse, and SSH hosts) (#15866)
Co-authored-by: hwantage <hwantagexsw2@gmail.com>
2026-08-22 23:00:31 -07:00
Neilandvam af2e825626 fix(worktree): stop warning about a stale local base branch that does not exist yet (#15331) (#15871)
Co-authored-by: vam <a@a.com>
2026-08-22 23:00:27 -07:00
Neilandkriptoburak 8d1557c225 fix(skills): discover Hermes home skills (#15862)
Co-authored-by: kriptoburak <kriptoburak@users.noreply.github.com>
2026-08-22 23:00:23 -07:00
Brennan Benson 9ea2636b38 fix(remote): keep editor focus after unfocused HTML side preview (STA-5001) (#15716)
Opening a remote HTML preview to the side created an empty split and made it
the active group. The next host session-tab snapshot still had the terminal
active, so the client treated that empty group as a terminal focus change.

Do not activate the empty split for unfocused remote previews, and when a
reserved preview group is still empty, keep the sibling editor as the
visible tab instead of following the host terminal.
2026-08-22 22:37:40 -07:00
Neil 0cb076f03c fix(dictation): address visualizer review feedback (#16035) 2026-08-22 22:34:38 -07:00
Neil 838f5bfb75 fix(secrets): tell Linux users when their secrets are only obfuscated (#16033)
On Linux with no keyring, Electron falls back to the `basic_text` backend, which
"encrypts" with a hardcoded password. `isEncryptionAvailable()` returns true for
it, so Orca reported those secrets as sealed. They are not.

The obvious fix — returning false for basic_text — is wrong and would have been a
credential regression: `decryptWithStatus()` skips decryption entirely when
encryption is unavailable, so every already-stored secret would read back empty.
Sealing genuinely works on basic_text and must keep working.

So capability and trust are now separate questions. `isEncryptionAvailable()`
still answers "can this host seal and unseal", and `describeProtectionGap()`
(renamed from `describeUnavailable`) answers "is my data actually protected",
covering both no-sealing and weak-sealing.

That method had no production caller — the port documented a promise nothing
kept. `reportSecretProtectionGap()` now reads it at startup. A user-visible
surface is follow-up; this at least stops the silence.

Adds a bootstrap wiring guard over all nine host port installs. The no-op
defaults are correct for a renderer-less host and silently wrong for the desktop,
and a dropped or reordered install fails no existing test. Verified in both
directions: it fails when an install is removed, and when one moves after the
runtime is constructed.
2026-08-22 22:30:11 -07:00
erish b851a5e13f fix(github): add GHES avatar fallback to PR and task views (#13981)
* fix(pr-page): route remaining user avatars through GitHubUserAvatar

On a private-mode GitHub Enterprise instance the stored avatar URL 302s to
/login, and the renderer's default Electron session carries no cookie, so the
image never loads. #8784 added GitHubUserAvatar for exactly this — it degrades
to an initials placeholder via onError — but three call sites in
PullRequestPage kept a bare <img>: the reviewer picker, the comment author,
and the @ mention suggestions.

Each only guarded on avatarUrl being absent, so on GHE the URL is present, the
placeholder branch never runs, and a broken image is left on screen. The
authorAvatarUrl type comment already documents the intended contract ("falls
back to the login URL and finally an initials placeholder").

GitHubUserAvatar was already imported in this file for the PR author, so this
makes all five avatars in the page consistent. Note the three switched slots
now carry the shared border/bg styling, matching the two that already did.

Add a boundary test that fails if any avatar is rendered through a bare <img>
again.

Fixes #13976

* fix(task-page): route GitHub avatar cells through GitHubUserAvatar too

Auditing the rest of the GHE avatar path turned up the same bare <img> in
TaskPage: GitHubAssigneeAvatar, GHAssigneesCell and PRReviewCell. Fixing only
the PR page would leave half of #13976 in place.

GitHubAssigneeAvatar is the clearest case — ReviewChipAvatar directly above it
already renders through GitHubUserAvatar, so two adjacent functions disagreed
on how a GitHub user avatar is drawn. Its border also moves from
border-border/40 to /50, matching the neighbour.

Linear member avatars in this file are left alone; they use their own provider
path and are out of scope here.

Move the regression assertions into the existing repro-8784 file rather than a
new boundary test — that file already guards PullRequestPage and TaskPage
together, so it is where this belongs. The PR-page check matches the <img>
pattern instead of specific field names, so a rename or a newly added avatar
slot cannot slip past it; the TaskPage check is scoped per function to avoid
catching the Linear cells.

* test(github): scope the avatar guard per call site and cover TaskPage names

Addresses review feedback on the regression guard.

The field-name regex missed aliases and resolver expressions, and the PR-page
assertion did not require GitHubUserAvatar in each migrated slot — deleting all
three would have passed. Reject any bare <img> within the component scope
instead, which is safe now that every assertion is scoped to one function.

Drive all six slots from one table so each gets its own named case, and extend
the display-name contract to TaskPage, which previously went unchecked. The
ConversationTab entry carries displayName: null because PRComment has no
display-name field.

Reverting the fix now fails 11 cases instead of 3.
2026-08-22 22:11:55 -07:00
Jinjing 36dfcc3955 fix(terminal): arm the restore baseline only on a snapshot that painted content (#16009)
The restored-snapshot baseline permanently drops every delivery chunk at or
below the snapshot's seq, on the model's claim that those bytes are already
painted. reconcileChunkAgainstRestoredSnapshot recovers when the baseline
under-reports (a gap ahead re-restores; a rawLength mismatch re-restores) but
has no path for a baseline that over-reports: those chunks return
drop-duplicate forever, and an idle shell never re-sends them.

Gate arming on whether the snapshot painted printable cells. A snapshot
claiming seq > 0 while painting nothing cannot be the rendering of the output
it claims to cover, so the claim is disproven and the redelivery is the only
remaining copy.

Also pins disposeHeadlessTerminal's two-part write ordering, which was
previously unpinned and silently reversible.

Refs STA-5179
2026-08-22 22:01:45 -07:00
Neil 03fcfdfb92 feat(orcad): boot the Orca runtime on plain Node (#15968)
* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* feat(orcad): boot the Orca runtime on plain Node

Closes the last two Electron couplings and makes `orcad` a working artifact:
a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git
worktree and round-trips a PTY — with zero `require("electron")`.

Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its
test asserts exactly that: any reachable electron import is a regression.

- speech: inject the service factories, so importing ModelManager for its type
  no longer drags Electron's streaming net.request into the graph
- filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those
  maps arrives through an ipcMain handler carrying a renderer sender, so a host
  with no renderer has nothing to close, restore or forget — the inert default
  is what the desktop code does against empty maps, not a stub hiding work
- user-data-path / profile-storage-paths: resolve userData through
  AppEnvironment. These surfaced only once orcad pulled the store in

Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives
the re-imported graph a fresh module copy, so a binding installed before the
reset silently read back as uninstalled.

The acceptance smoke drives both hosts through one code path (`--target
orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the
same contract of each. Wired into PR CI.

* test(smoke): remove the seeded workspace container, not just the worktree

* test(smoke): surface the server's stderr when it dies before ready

* fix(smoke): build node-pty for Node before booting orcad in CI

* fix(smoke): drive the CLI built from this checkout, not one on PATH

* docs(ratchet): say the baseline must stay empty, not merely shrink

* build(orcad): externalize only the native modules actually in the graph
2026-08-22 21:47:46 -07:00
Neil dee48498b2 feat(dictation): add sound-reactive grape visualizer (#16017)
* feat(dictation): add sound-reactive grape visualizer

* perf: scope dictation meter updates
2026-08-22 21:40:47 -07:00
Neil f975035809 refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927)
* refactor(preflight): split agent detection out of the ipcMain registration

First of the IPC extractions the revised design requires. `src/main/ipc/preflight.ts`
mixed 285 lines of agent/tool detection with 35 lines of `ipcMain.handle`
registration, and the runtime calls that detection during normal operation
(`orca-runtime.ts:573`, plus the preflight RPC methods). So the runtime dragged
`ipcMain` into its graph to reach pure logic.

Detection moves to `src/main/preflight/agent-detection.ts` — named for what it
contains, per AGENTS.md. `ipc/preflight.ts` keeps only the handler registration and
re-exports the domain module so existing importers are unaffected. The runtime and
its RPC methods now import the domain module directly.

Ratchet baseline 36 → 35: `src/main/ipc/preflight.ts` is no longer reachable from
the runtime. The gate detected the improvement and refused to pass until the
baseline tightened, which is the behaviour it was built for.

Verified: 2 files / 1,187 tests pass across every suite touching preflight;
`pnpm typecheck` clean; `oxlint` clean.

* refactor(ssh): split the SSH target registry out of the ipcMain module

Second IPC extraction, and by far the biggest win: this removes **eight** modules
from the runtime's Electron graph, taking the ratchet baseline 35 → 27.

The runtime needed five thin accessors from `src/main/ipc/ssh.ts` —
`connectRegisteredSshTarget`, `getRegisteredSshState`, `listRegisteredSshTargets`,
`listRegisteredRemovedSshTargetLabels`, `getActiveMultiplexer`. Each is a one-line
read over module-level state. Importing them dragged in `ipcMain`, `powerMonitor`
and a `BrowserWindow` accessor — and, transitively, `ipc/pty.ts` (8,031 lines),
`ssh-browse`, `ssh-passphrase`, `ssh-relay-deploy`, `ssh-remote-cli-host-passthrough`,
`wsl-hook-relay-launch` and `user-data-path`.

`src/main/ssh/ssh-target-registry.ts` now holds that state plus its accessors.
`registerSshHandlers` populates it; the runtime reads it. The indirection is kept
deliberately: SSH providers register after construction and may reconnect, so
callers must resolve the current generation rather than freeze one.
`ipc/ssh.ts` re-exports all five, so non-test importers are unaffected.

`connectRegisteredSshTarget` still throws `ssh_handlers_not_registered` when no
handler layer registered — a headless host must fail loudly rather than report a
target as unreachable, which would read as `exited` (see ssh-execution-boundary.md).

Verified: 9 files / 59 tests across the ssh, automations and trust-preset suites;
orca-runtime.test.ts 1,183 pass; `pnpm typecheck` clean; `oxlint` clean.

* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* test(ssh): mock the SSH target registry alongside the ipc/ssh mock

Thirty-eight suites mocked `vi.mock('./ssh')` for `getActiveMultiplexer`. That
factory went inert when production started importing the accessor from
`../ssh/ssh-target-registry`, so the real module loaded and the assertions drifted.

Adds a companion registry mock returning the same stub, plus a
`sshTargetRegistryModuleMock` builder beside the existing `sshModuleMock` so the
shared harness stays one place. No assertion changed.

Found by a full-suite run: the targeted ssh/runtime suites were green while
30 tests in ipc/worktrees and ipc/repos were not.

* refactor(runtime): read app paths and the packaged flag through the port

`orca-runtime.ts` is the last module in its own graph that imports `electron`
directly. Nineteen of its uses were `app.getPath` (12) and `app.isPackaged` (7) —
exactly what the AppEnvironment port already covers.

Also removes a dead `const { app } = require('electron')` inside
`getOrchestrationDb`. It was left unused once the path came from the port, and it
is precisely the dynamic-require pattern `plain-node-entry-guard.ts` exists to
catch, sitting in the runtime's own constructor path.

What still binds `orca-runtime.ts` to Electron is now three sites, not nineteen:
`new Notification(...)` (one), `BrowserWindow.fromId` (one), and the
`ipcMain.on('terminal:tabCreateReply')` renderer round-trip — which is the browser
tab path, and the same one that would hang a headless host for ten seconds.

Two suites drove `electronMocks.app.isPackaged` directly; they now install a fake
AppEnvironment reading the same mutable field, so their per-test toggles work
unchanged and no assertion moved.

Verified: 376 files / 4,717 tests across src/main/runtime; typecheck and oxlint clean.

* test(serve): add the built-artifact terminal round-trip acceptance smoke

"The server started" proves almost nothing. Terminal creation dispatches into
OrcaRuntimeService, and without an installed headless PTY controller that path
falls through to a renderer reply that never arrives and times out after ten
seconds. A boot probe, a port bind, and a `host.platform` call all pass against a
server whose terminals are dead — which is exactly the gap the design doc's own
boot proof was retracted for.

This boots the BUILT `out/main/index.js --serve`, parses its ready payload, pairs a
real client over the advertised endpoint, lists worktrees, creates a terminal, runs
a command through the PTY, asserts the output comes back, and asserts clean
shutdown. It drives nothing but the public pairing + RPC surface, so the same
script is the acceptance gate a future Node-only backend must pass unchanged.

The sentinel invokes `process.execPath` rather than `echo`, because the shell
differs per platform and node does not.

Verified both directions: passes against the real server, and fails with an
actionable message when the command produces no output — a smoke that cannot fail
is worthless.

* fix(ssh): fail loudly when the multiplexer resolver was never installed

`getActiveMultiplexer` resolves through a resolver that `ipc/ssh.ts` installs at
module scope. A process that never loads the SSH layer — which is the whole point
of the Node-only backend — would get `undefined` from every call.

`undefined` already means something specific here: "not connected". So a missing
resolver and a disconnected target were indistinguishable, and a host with no SSH
layer would quietly report every target as not connected. That is the
unverifiable-reported-as-exited conflation `docs/reference/ssh-execution-boundary.md`
exists to prevent — the doc is explicit that absence of contact is never evidence
of absence of the thing.

A missing resolver is a wiring error, not a connection state, so it throws, matching
what `connectRegisteredSshTarget` already does for unregistered handlers.

Verified: 432 files / 4,759 tests across ipc, ssh, preflight, automations and trust
presets; typecheck and oxlint clean.

* refactor(pty): stop faking a BrowserWindow for the headless PTY path

`registerHeadlessPtyRuntime` passed `registerPtyHandlers` a stub object cast to
`BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send`
was a no-op — a window-shaped thing that lied about being a window, purely to
satisfy the type. Adversarial review named it as the same "looks fine, silently
returns a lie" pattern this codebase rejects elsewhere, and it is the shape that
keeps `electron` on a path that otherwise needs none.

`registerPtyHandlers` now takes `BrowserWindow | null`. An absent renderer is
semantically identical to a destroyed one — all 42 call sites already guarded on
`isDestroyed()` and skipped — so `src/main/ipc/pty-renderer-surface.ts` states that
directly: `isRendererGone`, `sendToRenderer`, `rendererWebContents`. The compound
`isDestroyed() || webContents.isDestroyed()` guards collapse into one predicate.

`isPtyWriteEventFromMainWindow` becomes null-tolerant and fails closed: with no
renderer no sender can legitimately match, so every write is rejected. Those
handlers cannot fire headless today, but failing closed is the right answer if that
ever changes.

This is the precondition for installing a PTY controller without Electron, which is
what a Node-only backend needs and what `terminal.create` actually calls.

Verified: 129 files / 2,473 tests across ipc/pty, providers and orca-runtime; the
built-artifact acceptance smoke still passes end-to-end (boot → pair →
terminal.create → sentinel → close), which is the check that matters most here
since this changes the headless PTY path itself; typecheck and oxlint clean.

* refactor(pty): read app paths and the packaged flag through the port

Follows the fake-window removal. `ipc/pty.ts` had nine `app.*` reads — all
`getPath`, `getVersion` or `isPackaged` — which the AppEnvironment port already
covers. The `BrowserWindow` import was also dead after the null-window change.

What still binds this file to Electron is now `ipcMain` (75 uses, all handler
registration) and `powerMonitor` (2). That is a clean statement of the remaining
job: split logic from registration, the same shape already applied to preflight
and the SSH registry.

Test wiring: the shared `pty-ipc-suite-environment` beforeEach installs a fake
AppEnvironment that reads through the existing `vi.mock('electron')` app object
rather than freezing values — suites toggle `app.isPackaged` mid-test to exercise
dev-mode spawn paths, so the port has to observe the same mutable field. One edit
in the shared harness covers every pty suite.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes; typecheck and oxlint clean; ratchet unchanged at 25.

* refactor(pty): inject the ipcMain surface so the PTY module loads without Electron

This closes the round-3 blocker: "the doc never says how orcad installs
setPtyController without Electron."

`registerPtyHandlers` owns the `RuntimePtyController` that `terminal.create`
actually spawns through — the thing a Node backend needs and cannot get from the
provider thunks. The module was otherwise host-agnostic already; the only thing
pinning 8,031 lines to Electron was a static `ipcMain` / `powerMonitor` import used
purely to register renderer handlers that no headless host will ever receive.

`src/main/ipc/pty-host-bindings.ts` makes those surfaces settable, defaulting to
no-ops. Unlike AppEnvironment and SecretStore, the default does NOT throw: a host
with no renderer legitimately has nothing to register against, so not registering
handlers nobody can call is correct rather than a hidden downgrade. The desktop
installs the real objects in `attach-main-window-services` before its handlers run.

Also converts the remaining electron import to a top-level `import type`. oxlint's
`no-import-type-side-effects` caught that inline `type` specifiers still leave a
side-effect import — precisely the "type-only is not enough if esbuild still emits
require('electron')" trap a reviewer flagged.

**`src/main/ipc/pty.ts` now bundles with zero `require("electron")`.** A Node entry
can call `registerPtyHandlers(null, runtime, …)` and get a working PTY controller.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes end-to-end — which is the check that matters, since this
changes how every PTY handler registers; typecheck and oxlint clean.

* fix(pty-bindings): drop two unused eslint-disable directives

CI runs oxlint with unused-disable reporting; the two
`@typescript-eslint/no-explicit-any` suppressions I added were never triggered by
any enabled rule, so they failed static analysis as dead directives. The `any[]`
rest args stay — they mirror electron's own IpcMain signature, and narrowing them
would reject the real object at the desktop call site.

Verified with the exact CI invocation: `oxlint --format github` reports 0 warnings,
0 errors across the repo.

* fix(pty): install the host bindings per process, not per window

A real regression my own change introduced, caught by the SSH docker E2E
(`paired-startup-exec-readiness` — "recovers startup exec through a headed paired
desktop owner"). It reproduced on rerun, so it was not a flake.

`setPtyHostBindings` was called inside `attachMainWindowServices`, i.e. when a
window attaches. But `registerHeadlessPtyRuntime` (index.ts:3163) calls
`registerPtyHandlers` on the serve path *before* any window exists — so those
handlers registered against the no-op default and never reached the real `ipcMain`.
A paired desktop owner then attached to a runtime whose PTY handlers were wired to
nothing.

The bindings describe the *host*, not the *window*: an Electron main process always
has `ipcMain`, whether or not a window is open. Installing them beside
`setAppEnvironment`/`setSecretStore` at the top of bootstrap fixes both paths.

Verified: 128 files / 1,290 tests; the built-artifact acceptance smoke passes;
typecheck clean; `oxlint --format github` (the exact CI invocation) reports 0/0.

* feat(orcad): de-electron the runtime core and add the Node entry + build gate

**`src/main/runtime/orca-runtime.ts` — 41,048 lines — no longer imports electron.**
Its last three sites go through `runtime-desktop-surface.ts`: a native notification,
the authoritative-window lookup, and the one `ipcMain` channel used by the
renderer-backed tab-create fallback. All three are unreachable without a renderer —
`createTerminal` already takes the background branch when no window exists (#10333) —
so a Node host installs none and the runtime relays notifications to paired clients,
which is the better destination anyway. Ratchet 25 → 24.

Adds `src/main/orcad/orcad-entry.ts`: Node host adapters plus a `startOrcad` that
constructs the runtime, installs the PTY controller via `registerPtyHandlers(null, …)`,
and serves RPC. It sets two defaults the constructor gets wrong for a headless host —
`canRecoverPersistentLocalPtys: false` (no daemon here) and
`getDesktopWindowStatus: 'blocked'` (a Node host can never be promoted to a desktop
window, which is what `'openable'` claims).

Adds `config/scripts/build-orcad.mjs`, which **currently fails, on purpose**: 25
modules still import electron (browser and speech clusters, plugins, jira/proxy,
filesystem-watcher, and four `require('electron').app` one-liners). It names them.

Two bugs found while building it, both worth recording:
- The first bundle looked clean and was not. `electron` was bundleable, so esbuild
  rewrote the metafile `path` to the resolved file under node_modules and a check for
  `path === 'electron'` passed while the package was in the bundle — it failed at
  runtime with electron's own installer message. The check now reads `original`, and
  electron is marked external so a residual import fails loudly instead.
- `jsonc-parser`'s UMD build breaks the bundle at load; aliased to its ESM entry, the
  same fix `build-relay.mjs` already carries.

Verified: desktop unchanged — the built-artifact acceptance smoke passes, runtime/pty/
provider suites green, typecheck clean, `oxlint --format github` 0/0.

* refactor(host): drop the last two require('electron') app lookups

`computer/sidecar-client.ts` and `ports/port-scan-command-client.ts` read the app
root through `require('electron').app` inside a try/catch. Both were already correct
under plain Node at runtime — they return null when it throws — but the literal text
fails the plain-Node entry guard regardless, which is why port-scan carried a comment
warning it must never become reachable from a fork entry.

Reading the AppEnvironment port gives the identical "no app root here" answer without
the text, so that warning is now obsolete and the comment says so.

Ratchet 24 → 22. Every remaining entry is a real coupling: the browser cluster (15,
which variant B does not ship), speech (2), plugins (2), and jira/proxy-settings (2,
needing an HttpClient port for Chromium session partitions).

Verified: 25 files / 209 tests; acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* docs(orcad): record that the ratchet under-counts orcad's graph

The ratchet reports 22 electron importers; the orcad build reports 23. The extra is
agent-hooks/wsl-hook-relay-launch.ts, and the cause is a gap in the gate rather than
a rounding error: the ratchet measures what orca-runtime + runtime-rpc reach, while
orcad's entry also imports ipc/pty directly to install the PTY controller.

Once orcad ships it must become a ratchet entry point, or the two numbers drift and
the gate quietly stops covering the artifact it exists for.

* refactor(runtime): inject the browser commands factory

Drops 14 modules from the runtime's Electron graph in one change — the whole Chromium
browser cluster. Ratchet 22 → 8.

`OrcaRuntimeService` constructed `RuntimeBrowserCommands` as a field initializer, and
that construction is what pulled in `BrowserWindow`, `session`, `webContents` and the
cookie jars. Importing the class for its *type* is free; only building it costs.

So the class import becomes `import type`, and the instance comes from
`runtime-browser-commands-factory.ts`. The desktop installs the real factory at the
Electron entry. **All ~80 existing `this.browserCommands.*.bind(...)` delegations are
untouched** — a review round specifically warned that rewriting those was the
expensive, risky part, and this avoids it entirely.

With no factory installed, browser commands reject per call with `browser_unavailable`
rather than resolving to a stub that silently succeeds. The runtime already filters
browser capabilities out of `getStatus()` when no backend exists, so clients do not
offer the affordance in the first place.

Also corrects a stale comment in `pty-renderer-surface.ts` that still described the
fake window as present tense; it was deleted two commits ago.

Verified: 451 files / 5,513 tests across `src/main/browser` and `src/main/runtime` —
the entire browser automation suite; the built-artifact acceptance smoke passes;
`pnpm typecheck` and `oxlint --format github` clean.

* refactor(host): extract the plugin client list and port two app lookups

Ratchet 8 → 5.

- `listPluginsForClients` moves to `src/main/plugins/plugin-client-list.ts`. It needed
  only three `plugins/*` helpers, none of them Electron — it was colocated with
  `ipcMain.handle` registrations, so the runtime's `plugins.list` RPC dragged all of
  Electron in to call a function that reads a lockfile. Same shape as preflight.
  Dropping it also releases `ipc/plugin-marketplaces.ts`.
- `agent-hooks/wsl-hook-relay-launch.ts` and `speech/stt-service.ts` read `getAppPath`
  and `isPackaged` through the AppEnvironment port.

The five that remain are all genuinely Chromium and need the HttpClient port or a
watcher split, not another mechanical swap: `browser/cdp-bridge` (webContents),
`ipc/filesystem-watcher` (ipcMain), `jira/authenticated-request` and
`network/proxy-settings` (net + session partitions), `speech/model-manager`
(`net.request`, which honors app proxy settings that Node https does not — replacing
it is a behaviour change, not a rename).

Verified: 219 files / 1,922 tests across plugins, speech, agent-hooks and the runtime
RPC methods; the built-artifact acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* refactor(network): resolve the default proxy session lazily

Ratchet 5 → 4.

`proxy-settings.ts` needed exactly one Electron value: `session.defaultSession`, as
the fallback when a caller does not pass `options.proxySession`. Callers could already
inject a session; only the default was hard-wired. It now comes from a settable
resolver, so the module loads under plain Node.

**A resolver rather than a Session, because a Session eagerly throws.** The first
attempt installed `session.defaultSession` directly in pre-ready bootstrap and broke
startup outright — `TypeError: Session can only be received when app is ready`. The
acceptance smoke caught it before commit. Deferring to first use is always after ready.

Behaviour with no session is not a degradation: there is no Chromium proxy config to
discover, so `resolveProxy` is skipped and the environment variables become the whole
answer rather than a fallback. Applying rules to a session that does not exist is
likewise skipped; settings are still honoured because outbound requests read the env.

This reaches past Jira — a review round noted `ensureElectronProxyFromEnvironment` is
also on the Claude HTTP path via `oauth-refresh.ts` and `rate-limits/claude-fetcher.ts`.

Verified: 48 files / 526 tests across network, jira and rate-limits; the
built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean.

* fix(index): merge the duplicate proxy-settings import

CI's code-quality lint (`oxlint --config config/oxlint-code-quality-native-plugins.json
--deny-warnings`) flags a module imported twice in one file. My earlier insertion added
a second `./network/proxy-settings` import beside the existing one.

Verified with CI's exact invocation: exit 0.

* refactor(network): add the HttpClient port and lift BrowserError out of cdp-bridge

Ratchet 4 → 2.

Two unrelated couplings, both of the same shape — a small thing living inside a
Chromium-heavy file.

`BrowserError` is a seven-line error class with no dependencies, but it lived in
`browser/cdp-bridge.ts`, which imports `webContents`. The runtime catches that type on
paths with nothing to do with CDP, so one import kept a Node host from loading the
runtime at all. Moved to `browser/browser-error.ts`; cdp-bridge re-exports it.

`jira/authenticated-request.ts` fetches through `net.fetch` and reads
`session.defaultSession`. `network/http-client.ts` makes both settable. This one is a
**named port rather than a silent fallback, because the fallback is not transparent**:
Electron's net follows Chromium session/proxy state, avoids undici's stale keep-alive
sockets after a VPN path change, and sends a Chrome user agent that Jira's XSRF check
depends on. A Node host gets `globalThis.fetch`, reads proxy config from the
environment, and sends Node's user agent. That difference is documented at the port.

`session.defaultSession` is read per call, not captured at install — it throws before
the app is ready, which is the mistake the previous commit made and the acceptance
smoke caught.

Test wiring: `jira/client.test.ts` installs the port *inside* `loadClientModule`, after
its `vi.resetModules()`, since the reset gives the module a fresh singleton.

Verified: 461 files / 5,616 tests across jira, browser, network and runtime; the
built-artifact acceptance smoke passes; typecheck, `oxlint --format github` and the
code-quality lint with `--deny-warnings` all clean.

* fix(http-client): register the Node fetch fallback with the call-site audit

`global-fetch-call-site-audit.test.ts` guards every global-fetch use, because the
global runs on undici where an unread response body can crash the whole process
(orca#8695). The HttpClient port's Node fallback is a new such call site and was
unregistered — the guard caught it in a full-suite run.

Registered with the reasoning, and the port's doc comment now states the body-safety
contract explicitly: it hands the Response straight to its caller and never inspects
it, so the consume/cancel obligation stays exactly where it already was — with the
caller, unchanged from when they called Electron's net directly.

Two comments elsewhere mentioned the global by name and tripped the line scan as false
positives; reworded to describe the behaviour rather than name the API.

Verified: audit passes; typecheck and `oxlint --format github` clean.

* fix(app-environment): read hasAppEnvironment through the realm slot
2026-08-22 21:34:39 -07:00
Neil 6785dc092d fix(composer): close the Create Workspace dialog on the first Escape (#16027)
* fix(composer): close the Create Workspace dialog on the first Escape

The modal copied the page-level "Esc blurs the focused field, then closes"
rule from TaskPage/Automations. On a page that rule protects a focus the
user chose; this dialog auto-focuses the name input on open, so its
capture-phase handler preventDefault'd every first Escape (which also
suppressed Radix's dismissal, since DismissableLayer skips a
defaultPrevented event) and the dialog could only be closed with two
presses.

Drop the Escape branch and let the dialog's dismissable layer own it.
Radix dismisses only the topmost layer, so nested popovers, selects and
dialogs still consume their own Escape first.

* test(e2e): pin the composer's auto-focus as the reason one Escape must close it
2026-08-22 21:33:54 -07:00
NeilandMelih 7ce11dcf55 fix(agent-resume): restore Copilot sessions after restart (#15879)
Co-authored-by: Melih <mberatsanli@gmail.com>
2026-08-22 21:24:44 -07:00
Neil 15fd723bc4 fix(terminal): drop conda's orphaned CONDA_SHLVL sentinel (#15885) 2026-08-22 21:24:18 -07:00
Neil 636f428c25 fix(rate-limits): stop showing Gemini failures as Antigravity "Refresh failed" (#15876) 2026-08-22 21:23:51 -07:00
Neil cbea7530b4 build(runtime): gate new Electron imports reachable from the Orca runtime (#15919)
* build(runtime): gate new Electron imports reachable from the Orca runtime

The runtime is meant to become host-agnostic so it can also run on plain Node,
but nothing enforced that. `orca-runtime.ts` reaches dozens of modules that
import `electron`, and the count grows silently: the import that breaks
portability is usually several hops away, so no reviewer sees the edge.

Add a reachability ratchet, modelled on the existing max-lines one. It bundles
the runtime and its RPC server with esbuild, reads the metafile for every module
importing `electron`, and diffs that against a checked-in baseline. A new module
fails; a removed one forces the baseline to tighten. The list may only shrink.

A per-file lint rule cannot do this — the point is precisely the transitive
edges — so this runs as a build gate in `pnpm lint`.

Baseline starts at 36, down from 50 before the SecretStore and AppEnvironment
ports landed, which is the migration made measurable.

Verified: gate passes clean, fails with an actionable message when an `electron`
import is added to a runtime module, and passes again when reverted.

* fix(runtime-ratchet): resolve paths from the script, not the caller's cwd

Run from anywhere but the repo root, the gate died with an unhandled ENOENT stack
instead of a usable message. It failed closed, so it was never unsafe — just
undebuggable. Anchor ROOT to import.meta.dirname and pass absWorkingDir to esbuild
so metafile keys stay repo-relative.

* ci(runtime-ratchet): actually run the gate in CI

The ratchet was wired into the `lint` npm script, but CI's static-analysis job
runs the individual checks rather than `pnpm lint`, so the gate would never have
fired on a PR — it would have looked enforced while enforcing nothing.

Runs on ubuntu-latest alongside the max-lines ratchet, so the checked-in baseline
is only ever produced by one platform.

* fix(runtime-ratchet): mark native addons external so CI can run the gate

ssh2's optional cpu-features dep points at a prebuilt .node that only exists
where a build toolchain has run. Loading it made the gate pass locally and
hard-fail on CI with 'Could not resolve ../build/Release/cpufeatures.node'.

The gate only reads the import graph, never the addon, so resolve every .node to
an external stub instead. Verified by hiding the local prebuild — which is CI's
state — and re-running: still 36 entries, exit 0.

* fix(runtime-ratchet): stop the gate failing open on Windows

The entry guard compared import.meta.url against a `file://${process.argv[1]}`
template. On Windows argv[1] is a native path (C:\repo\...) while import.meta.url
is file:///C:/repo/..., so they never match: main() never ran and `pnpm lint`
exited 0 on Windows without bundling, reading the baseline, or enforcing anything.

Use pathToFileURL, which is the idiom check-max-lines-ratchet.mjs:225 already uses.
CI runs this on ubuntu so enforcement was never actually lost, but a Windows
developer got a green gate that checked nothing.
2026-08-22 21:22:07 -07:00
Neil ac103f5d90 fix(project-groups): route rename and delete to the group's owning host (#15889) 2026-08-22 21:15:31 -07:00
Neilandterry-li-hm 5f2fbd862e fix(pty): wrap codex without expanding or destroying a user alias (#15873)
Co-authored-by: terry-li-hm <12233004+terry-li-hm@users.noreply.github.com>
2026-08-22 21:15:25 -07:00
NeilandTauri-EPO 8c1e6ad0cf test(orchestration): distinguish the heartbeat straggler guard from the row's initial null (#15869)
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
2026-08-22 21:15:22 -07:00
NeilandTauri-EPO d3869cd0b4 test(runtime): use the platform submit delay in the cancellation-during-verification test (#15870)
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
2026-08-22 21:15:18 -07:00
Neil 0bbc6c80e8 refactor(host): route app paths and version through an AppEnvironment port (#16019)
* refactor(host): route app paths and version through an AppEnvironment port

`app.getPath('userData')` is the single largest Electron coupling in the main
process — 37 call sites — and it is one of the things stopping the Orca runtime
from booting on plain Node. Give it the same treatment as SecretStore.

- `src/shared/app-environment.ts` — the port plus a settable registry, covering
  the members the runtime's module graph actually reads: paths, app path,
  version, packaged flag, shutdown hook, exit, and Chromium process metrics.
  `getAppEnvironment()` throws until installed, for the same reason the secret
  store does: a silent default resolves `userData` to the wrong directory and the
  caller writes real state there before anyone notices. No `node:` imports,
  because `src/shared/**` is in the web build graph.
- `src/main/host/electron-app-environment.ts` — the desktop adapter, a
  pass-through to `electron.app`.
- 9 modules migrated: telemetry, opencode/mimo/pi hook services,
  terminal-history-paths, terminal-scrollback-snapshots, cli-installer,
  clipboard-image-temp-file, memory/collector.

Deliberately NOT migrated: `src/main/browser/**`. That cluster is Chromium-
adjacent by nature — cookie jars, download destinations, offscreen pages — and a
Node backend does not ship it at all, so porting it buys nothing and churns
heavily-mocked suites. Also left alone for now: the call sites that additionally
touch `app.asar` path literals or `app.setName`, which need more than a
mechanical swap.

`getAppMetrics` stays on the port rather than being injected because
memory/collector.ts is its only caller and reads it from module scope; a Node
host returns [], having no Chromium processes to measure.

Test wiring: the secret-store setup file becomes `vitest-host-ports-setup.ts` and
installs both ports, exporting `fakeAppEnvironment`/`installFakeAppEnvironment`
so suites needing one specific member state only that instead of restating all
seven — which is boilerplate, and had pushed one suite past the max-lines budget.

Verified: 159 files / 1651 tests pass across every touched area; `tsc` clean on
both the node and web projects; `oxlint` clean.

* fix(typecheck): list the vitest host-ports setup in the node project

Three suites import `installFakeAppEnvironment` from config/scripts, but that
directory is outside tsconfig.node.json's include list, so composite typecheck
failed with TS6307. Listing the one file matches how this config already pins
individual files it needs.

Local `tsc --composite false` does not reproduce this — only `pnpm typecheck`
does, which is what CI runs.

* refactor(host): drop two unused AppEnvironment exports

hasAppEnvironment() and resetAppEnvironmentForTests() had zero callers. The
secret-store equivalents are used, so these were mirror-symmetry rather than
need; add them back when something actually needs them.

* test(terminal-history): install the AppEnvironment fake instead of mocking electron

These three suites mocked `electron.app.getPath` to point at a fixture dir. The
production module now reads the port, so the mock was inert and the global test
default's temp dir won — which broke the WSL path assertions and every deletion
count.

Found by a full-suite run, not by the targeted checks around the migrated modules,
which is the argument for running the whole suite on a refactor this wide.

* test(host-ports): remove the per-environment temp dir on teardown

The setup allocated a mkdtemp directory at module scope, which vitest evaluates
once per test *environment* — one per test file, not one per worker. Nothing
removed them, so a full 6,000-file run left thousands behind.

Proven: with an isolated TMPDIR, a three-file run previously added directories and
now leaves zero.

* fix(app-environment): anchor the installed environment to a realm global

Same reason as the SecretStore: vi.resetModules() rebuilds the module registry,
and an environment installed before the reset read back as uninstalled.
2026-08-22 21:12:23 -07:00
Neil e9e238c883 refactor(wsl): delete the environment-policy layer the reviews kept failing on (#16007)
* refactor(wsl): delete the environment-policy layer the reviews kept failing on

A design council (Opus, Grok, GPT-5.6-Sol) reviewed the merged runner after it
took eleven review rounds to land. All three reached the same conclusion: the
invocation half is sound, the environment/probe half is not, and every round had
been debugging the second one.

The finding that settled it, from Opus: `environmentResolved` had **54
references, all in tests and the runner itself. Not one production reader.** The
safety mechanism the strict default existed for was never wired to anything, so
all 19 degrading sites reported absence with full confidence anyway -- #9725
live at every one, under comments claiming it was handled. Two of those comments
say so out loud; I wrote them.

Root cause, in one line: every knob existed only because a failed probe was
fatal. So it no longer is.

- `allowDegradedEnvironment` and `WslGuestEnvironmentUnavailableError` are gone.
  A missing login PATH is a fact in the result, not an exception. That deletes
  23 opt-outs, six catch-and-remap blocks, the transient/rejected cooldown
  split, `probedWithBudget`, and the 1.5x re-probe heuristic -- none of which
  had a reason to exist once the case stopped throwing.
- `lane` + `allowDegradedEnvironment` collapse into `loginPath: 'none' |
  'preferred'`. 19 of 23 sites passed the opt-out, and two said in comments that
  they did not want the login PATH at all: the flag had become the `'none'` the
  union was missing.
- The `interactive` lane is deleted. It had zero production callers and kept ~30
  lines of fence plumbing alive for tests only.

Net -98 production lines; the runner itself sheds 86 for 38.

Also carries three fixes from the W3 orphan-PR sweep I had not done:
- `WSL_UTF8=1` in the runner. My relay migration deleted the only place setting
  it, so wsl.exe's own error text arrived UTF-16LE and read as NUL-riddled.
  A regression I introduced. Credit: #9010 (Chang-Jin-Lee).
- `GITLAB_HOST` is now named in WSLENV, so a ported self-hosted host actually
  crosses into a distro-routed glab (#12557). Credit: #12558 (makoto-developer).
- The WSL skill-setup command pipes into `sh` instead of `eval "$(...)"`, whose
  nested quoting produced `word unexpected (expecting "in")` (#14292). Credit:
  #14785 (innocarpe).

* fix(wsl): restore the login PATH for the Codex availability lookup

loginPath:'none' on a PATH lookup reports an nvm-installed codex as absent,
which is #9725. A miss without a resolved environment is now 'could not
check', not 'not installed'.

Also hardens the guards that should have caught it:
- bashism ratchet is per-call, not per-file, and fails closed on lexer desync
- blankStringContents handles regex literals (an apostrophe in /'/g desynced
  the lexer, so the scan silently found zero calls)
- windowsHide allowlist 85 -> 80, stale once the lexer parsed those files

Credit: Grok (P0), GPT-Sol (ratchet gaps).

* test(wsl): close the two ratchet gaps that let planted spawns pass

- variable-indirected wsl.exe (`const b = 'wsl.exe'; spawnProcess(b)`) is now
  tracked, so the 5 files recorded only in a comment become real allowlist
  entries. Three actually spawn that way; the other two never spawned wsl.exe
  at all, so the prose record was wrong by three in the hiding direction.
- promisify(renamedAlias) is now resolved, so `const run = promisify(execFile)`
  behind an `execFile as x` import can no longer skip windowsHide.

Each verified by planting the violation, watching it fail, restoring, watching
it pass. Credit: GPT-Sol.

* fix(source-scan): stop the regex-literal reader from eating block comments

At index 0 there is no preceding token, so a file opening with a banner
comment had its `/*` read as a pattern and swallowed to the next slash --
110k characters of preload/index.ts, in the direction that hides offenders.

Measured across the tree, old lexer vs new: worst-case over-blanking drops
from -110564 to -1116 characters, and files that desync drop from 51 to 22.
The remaining extra blanking is regex interiors, which is the intent.

Regression tests for both lexer bugs, each verified to fail with its fix
reverted. The first draft of the comment test did not bind -- it asserted on
text after the swallowed span.

* fix(wsl): restore the unverifiable signal on the two remaining probe sites

Round 2. Three call sites used to throw when the login-PATH probe failed;
the redesign rewired one (Codex) and left two reporting confident absence.

- skill-wsl-provider-detection: the script ends in `|| true`, so a lookup
  without the login PATH exits 0 with empty stdout -- identical to 'nothing
  installed'. Callers skip the ~/.codex and ~/.claude skill roots on an empty
  list, losing an nvm-installed provider's skills.
- wsl-cli-installer: the dead catch is replaced by an explicit check. Its
  `case ":$PATH:"` probe otherwise answers from the distro default PATH and
  Settings states as fact that the CLI is not on PATH. Timeout is checked
  first, since a timed-out run also leaves the environment unresolved.

Also narrows the regex-literal prev-token set. '!', '+', '-', '>' and '}' are
value terminators as often as operators, so postfix `n-- / 2` and JSX
`<A size={14} /> : <B` were read as patterns and their spans blanked -- 13
live JSX spans, and one swallowed execFile call that left no desync behind.
False negatives only risk a desync, and desync fails closed.

Plus: WSL_UTF8 on the probe spawn (#9010 reached the runner, not the probe),
and the allowlist header I shuffled by sorting comments along with entries.

Credit: Grok (both P1s), Opus (lexer false positives).

* docs(wsl): drop the lane comments the redesign made false

The interactive lane is gone, so 'both lanes' and the fenced-stdout note
described code that no longer exists. Also states plainly that
environmentResolved is always true under loginPath:'none' -- the field cannot
rescue a PATH lookup that was mislabelled, which is how #9725 came back.

Credit: Grok.

* fix(wsl): stop piping user scripts into the shell's stdin

The W3 migration moved hooks from `wsl.exe --exec bash -c <script>` to a
script piped into `bash -s`. Anything the script runs that reads stdin then
drains the rest of the script, bash hits EOF and exits 0, and the caller logs
success -- an orca.yaml hook of `ssh -T git@github.com || true` followed by
`pnpm install` silently never installs.

Scripts now travel in argv by default, which is what the pre-migration code
did and what --exec makes safe. `scriptDelivery: 'stdin'` stays for the one
caller that needs it: the hook-relay installer embeds a base64 JS bundle far
past any command-line limit, and reads no stdin.

A runner test already described this exact EOF hazard -- for the login shell,
not for the guest command it was itself creating.

Credit: code review.

* fix(skills): make the unverifiable check unconditional, and stop double-probing

Round 3.

- provider detection threw only on an EMPTY result, so a degraded partial hit
  slipped through: `claude` visible on the default PATH via Windows interop
  plus an nvm-only `codex` returns a plausible ['claude'], and the caller then
  skips the ~/.codex skill roots for a provider that is installed. The
  installer already got this right with an unconditional throw.
- three sites asked for 'preferred' without needing it. The GROK_HOME probe
  runs its own `"$login_shell" -lc`, so the runner's probe was a second login
  shell eating up to half an 8s budget; the two skill scans are
  find/base64/head/printf/stat over $HOME.
- the indirection binder missed `private readonly x = 'wsl.exe'` (the
  modifier was captured as the name), backtick literals, and
  `spawnProcess(this.x)`. Commit 2bbbd99 claimed that gap closed; it now is,
  verified against all three shapes.

Credit: Grok.

* fix(child-process): keep the tail of output whose failure lands last

Two console-flash bugs the ratchet was carrying on its allowlist rather than
catching: daemon-process-inspection execs powershell.exe and the gemini
extractor execs `where gemini`, both console-subsystem, both without
windowsHide (#10488). Allowlist 80 -> 78.

And a migration regression: the hook-relay install used to keep a rolling
tail of stderr (`slice(-MAX)`), while runProcess's maxOutputBytes keeps the
head. A guest install that fails after pages of apt warnings therefore
reported the warnings instead of `mv: Read-only file system`. runProcess
takes retainOutput: 'tail' for output whose meaning is at the end.

Credit: code review.

* test(wsl): close the last two indirection shapes in the binder

`this.binary = 'wsl.exe'` has no declarator keyword, and a helper that just
returns the literal is a spawn one hop away that no regex can follow. The
return case fails closed only when the file also spawns something --
local-windows-terminal-runtime.ts returns the name as terminal metadata and
never spawns, so a blanket rule flagged it wrongly.

Verified against both shapes: planted, failed, restored, passed.

Credit: Opus.

* fix(preflight): stop reporting installed WSL CLIs as absent (#9725)

The last two probe sites that turned an unresolvable login PATH into a
confident negative. The native branch of detectInstalledAgents already
consults install dirs for exactly this reason ('PATH may still be unhydrated
on a cold GUI launch'); the WSL branch had no equivalent, so a cold distro
made an nvm-installed claude/codex read as not installed and told the user to
install a CLI their own terminal runs.

Ports that fallback to the guest: agent detection checks the version-manager
bin dirs for commands the PATH lookup missed, and the preflight command runner
APPENDS them to PATH -- append, never prepend, so a resolved login PATH stays
authoritative and a stale nvm version cannot shadow the real binary.

Tested by executing the generated scripts through /bin/sh against planted
binaries, since the behaviour is shell globbing and [ -x ]. Both the
nvm-discovery and the no-shadowing tests were verified to fail when reverted.

* fix(codex-accounts): hide the console on the legacy active-home migration

execFileSync('wsl.exe') with no windowsHide flashes a conhost and steals
foreground on a GUI-launched Orca (#10488). Sibling WSL spawns got this in
earlier commits; this one only had its quoting rewritten. Allowlist 75 -> 74.

Credit: code review.

* fix(windows): close the shell:true hole that made windowsHide a no-op

I un-allowlisted the gemini extractor after adding `windowsHide: true` to an
`exec()` call. `exec` implies `shell: true`, which this repo's own chokepoint
documents as silently making windowsHide a no-op (#14543) -- so the site still
flashed a conhost while reading as guarded. Now execFile('where.exe', …),
matching the relay sibling that already did it right.

The ratchet could not see that, which is why it passed. It now treats a call
that resolves to exec/execSync, or any `shell: true`, as unguarded regardless
of windowsHide -- including through renamed imports and a renamed promisify.

Also: a script over 8000 chars now falls back to stdin. Windows caps a command
line at 32767 and a user's orca.yaml hook is the one unbounded script Orca
runs (`run-both` concatenates two; a vendored installer is ~15KB), so argv
would fail to spawn outright. Degrading beats failing.

And the binder now sees `let p: string` ... `p = 'wsl.exe'`.

Each verified by planting. Credit: Grok.

* test(wsl): an opaque payload must declare its interpreter

My per-call bashism guard REPLACED the file-wide one, and that was a strict
regression: the real payloads are built in a separate function and passed as a
bare `script,`, so the bashism is never inside the call literal and the
per-call arm cannot fire. Deleting `shell: 'bash'` from skill-discovery-wsl
-- `done < <(find ...)` and `read -r -d ''`, the #14292 signature -- passed on
this branch and failed on main.

Reading through the identifier is guesswork. Requiring the call to name its
shell when the payload is not a literal is not, so seven POSIX call sites now
say `shell: 'sh'` -- no behaviour change, sh was already the default.

Two earlier attempts at this were wrong and are worth recording: a whole-file
BASHISM test blamed codex-accounts/service.ts, which correctly pins bash on its
four inline payloads and correctly leaves printf/mkdir unpinned; and excluding
call text still caught a bash payload belonging to a non-runner execFileSync.

Also: runProcessSync now refuses retainOutput:'tail' instead of silently
keeping the head, and the union docblock no longer describes stdin delivery.

Verified against both of the plants that exposed this. Credit: Opus.

* test(wsl): judge an opaque payload by the file, not by whether shell is set

Round 5. My previous rule -- opaque payload must have `shell:` -- was the
third guard fix in a row that came out weaker than what it replaced:
`shell: 'sh'` on a bash payload satisfied it, which is #14292 with extra
steps. Flipping skill-discovery-wsl's pin from bash to sh shipped green.

Now: strip the text of every call that already names bash, and if a bashism
survives anywhere in the file while a script-carrying call is not bash-pinned,
flag it. Stripping the bash-pinned calls is what keeps codex-accounts clean.

Also closes four ways to hide a call from the collector, each verified by
planting:
- `script: \`${bashism}\`` -- a template literal read as a visible literal
- `runWslProcess({ ...spec })` -- a spread hides script AND shell
- `Object.assign({ a }, { script })` -- the collector took the first `{`, so it
  now takes the whole argument list
- `import { runWslProcess as runWsl }` -- a renamed callee collected nothing,
  and zero calls read as zero violations

Not fixed, recorded instead: a computed `shell:` in the console guard. Matching
any non-false value also flags `shell: spawnConfig.shell`, a pass-through that
is false in every branch, and a false positive there costs an allowlist entry
that disables the guard for a whole correct file.

Credit: Grok.

* fix(preflight): make the guest fallback match the native one it claims to mirror

Three defects in the #9725 fix from earlier today, all found by executing the
generated scripts under real dash rather than reading them.

- $HOME containing a space word-split the unquoted dir list into a relative
  path, so every CLI read as absent -- the exact symptom the fix exists to
  remove. Each entry is quoted now; the nvm entry quotes only its prefix so the
  glob still expands.
- A directory passes `[ -x ]`, so ~/.local/bin/gemini/ was reported as an
  installed CLI that then fails to launch with EISDIR. The PATH half of the
  same script already guarded this, and so does the native twin.
- The header called this the "guest-side twin" of the native fallback while
  omitting four of its directories: volta, asdf, fnm and mise. A WSL user on
  any of those still had #9725 while the same user on native did not -- and
  asdf and mise are named in the motivating comment. The claim is now true.

Credit: Opus.

* test(wsl): mask bash-pinned calls by position, not by String.replace

`rest.replace(text, '')` with a string pattern removes only the FIRST match,
so two identically-written pinned calls left one behind and its bashism then
counted against an unrelated unpinned call in the same file. A body that also
occurred earlier as a substring would blank the wrong region entirely.

The collector now returns ranges and the mask is applied by index. Verified
both directions: two identical pinned bodies plus one unpinned call flags, and
the same file with all three pinned stays clean.

* test(wsl): fail closed on call shapes a regex cannot attribute

Round 6. Rather than widen the pattern again, treat the shapes it cannot
reason about as unreadable.

A regex cannot tell which object a key belongs to, so every round produced
another way to put the pin in one place and the payload in another:
`cond ? {pinned} : {unpinned}`, `{...} as WslSpec`, `Object.assign({a},{b})`.
A call whose SPEC is chosen by a ternary or spread -- one appearing before the
first `{` -- or which carries an `as` assertion is now flagged whenever the
file has a bashism, with no `shell: 'bash'` escape, because the substring test
that would grant the escape is exactly what cannot be trusted on these shapes.

A ternary INSIDE the object is not exotic: claude-accounts/service.ts:977 uses
one to choose a script line in a call that is already pinned, and treating that
as opaque would demand a second pin it already has. Nor is a nested call --
`script: `x ${shellQuote(p)}`` is how every payload here is built, and flagging
it would demand bash on POSIX payloads that must not have it.

Also follows `const run = runWslProcess`, generics and optional chaining, and
counts collected calls against mentions so a shape that slips the pattern reads
as unreadable rather than clean.

I tried the TypeScript parser first, which would remove the class outright.
TypeScript 7 is the native port and exposes no JS compiler API; oxc-parser
works but is transitive, and declaring it surfaced an unmet peer warning.
Recorded here so the next person does not repeat the detour.

Credit: Grok.

* fix(wsl): fish is a PATH lookup, and my lint check could not fail

Two things Opus caught that I had verified wrongly.

`wsl-fish-history-cleanup` passes `program: 'fish'` -- a bare name, so a PATH
lookup by definition, the exact class the earlier rounds hunted. I mapped it to
'none' and then defended that in an audit, because I read
`allowDegradedEnvironment: true` as "does not need the login PATH". It does not
mean that: it means "do not fail when the probe fails". The old call still USED
the login PATH whenever it got one, which is 'preferred'. Under 'none' a fish
from linuxbrew or nix is invisible and the cleanup throws. The truncated
comment left behind when the flag was deleted is finished too.

And `pnpm lint` has been failing on this branch while I reported it clean: I
grepped for `error eslint|error oxlint`, but oxlint prints the rule category
(`error typescript(array-type)`, `error unicorn(prefer-ternary)`). The grep
could not match, so it never failed. Checking the exit code instead surfaced a
third violation hidden behind the first two.

Credit: Opus.

* chore(wsl): clear the round-7 P2s

- Formatting: the branch owned 22 of the tree's 26 oxfmt failures because I
  never ran the formatter. Branch files now own none.
- resolveScriptDelivery was computed twice, in two places that must agree
  about argv shape and stdin payload. Resolved once and threaded through.
- The allowlist header said the list only shrinks while the branch added three
  entries. It grew because the scanner learned to follow a variable-bound
  'wsl.exe'; those three were previously recorded in prose, so the count was
  wrong by three in the direction that hides offenders. The header now says so.
- Two test comments still explained behaviour via the deleted
  allowDegradedEnvironment flag; a stray triple blank line; two adjacent JSDoc
  blocks where only the second attached.

Not taken: platform-guarding addWslEnvKeys. WSLENV is inert off Windows, and
the guard broke a test that asserts the key directly -- more surface than the
tidy is worth, so the reason is recorded at the call site instead.

Credit: Opus.

* test(preflight): plant a fabricated CLI name, not a real one

CI caught what my local run could not: the runner has a real /usr/bin/gh, so
`command -v gh` resolved to it and the planted nvm stub was never reached. The
fallback APPENDS, so that is the code behaving correctly -- the test was
asserting a property of my machine.

Both real-shell suites now plant `orca-fake-cli`, which exists nowhere.
Re-verified the same way as before: with the PATH fallback disabled the test
fails, with it restored it passes.

I declared this branch merge-ready without looking at CI. Local green is not
the gate.

* refactor(wsl): delete two knobs and a duplicated fallback

Elegance pass. The branch had grown from a deletion into a net addition, and
most of the growth was optional axes with one caller each.

- `retainOutput` is gone. One production caller wanted the tail of a 64KiB
  buffer; head-truncation only hurt because of that cap. The caller drops the
  cap, keeps the default, and slices the tail itself -- which is what the live
  relay next door already does. Two mechanisms for one job became one.
- `scriptDelivery` is gone. The size rule was already the whole design:
  argv unless the script is too long for a Windows command line. The option
  existed so a small Orca script could opt into stdin, and no such caller ever
  appeared. Both behaviours stay pinned: a huge script still goes to stdin, an
  ordinary one still leaves the hook's stdin free.
- Agent detection no longer walks the fallback dirs itself. It prepends the
  same PATH prelude the preflight command runner uses and lets the ordinary
  lookup do the work. Its bespoke walk had duplicated the lookup script's
  `! -d` guard -- and had missed it once, which is how a directory read as an
  installed CLI.

All 17 detection tests still pass unchanged, including the $HOME-with-a-space,
directory-is-not-a-CLI, and volta/asdf/fnm/mise cases, so the collapse is
behaviour-preserving rather than assumed to be.

Credit: Grok.

* fix(wsl): never name a path-shaped variable in WSLENV

`buildHostEnv` forwarded every caller-supplied key into WSLENV. wsl.exe
translates path-shaped variables between Windows and Linux form, so a caller
passing PATH would have replaced the guest's own PATH with a translated
Windows one -- silently, and fatally for every lookup after it.

No caller passes PATH today. The point of a chokepoint is that it does not
depend on that staying true.
2026-08-22 20:35:59 -07:00
erish 063b804298 fix(i18n): match CheckRunJobs succeeded to its sibling count-label register (#16013)
succeeded rendered as casual declarative 성공했다 ('it succeeded') next to
skipped's polite 건너뛰었습니다 and pending's noun-phrase 보류 중, in a summary
that joins all three after a count: '3 성공했다 · 1 건너뛰었습니다'. Machine
translation read succeeded as a finished sentence instead of the noun label
the other two siblings use. Switch to 성공 and pin it in the key-override
file so the catalog regen script can't revert it; #15875 fixed five other
keys in the same family but its hardcoded regression map didn't cover this
one.
2026-08-22 18:19:05 -07:00
fb5c9a1fe6 fix(ui): clear agent attention icon when Floating Workspace tab activated via keyboard (#15745)
* fix(ui): clear agent attention icon when Floating Workspace tab activated via keyboard

When a tab in the Floating Workspace is activated through keyboard shortcuts
while the Floating Workspace is not the active worktree, the agent completion
notifications were not being acknowledged, leaving the yellow attention icon
visible.

The issue was that `useAutoAckViewedAgent` only checked the global `activeTabId`,
which doesn't change when the Floating Workspace is not the active worktree.

Now the hook also watches the Floating Workspace's active tab
(`activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]`) and acknowledges agents
when they become visible in the Floating Workspace, regardless of whether it's
the active worktree.

Fixes #15700

* fix(ui): gate Floating Workspace auto-ack on panel visibility

The floating-workspace scan added in the previous commit had no visibility
gate. The panel stays mounted while closed (use-floating-workspace-panel:
shouldMountPanel), so its layout still resolves an active leaf and the hook
acked a floating agent completion the moment it landed — silently killing the
minimized toggle's attention dot (selectFloatingWorkspaceHasUnread), which is
the only "unseen floating activity" signal a closed panel has.

- Scan the floating tab only while the panel is actually visible
  (isFloatingWorkspacePanelVisible), so a closed panel keeps its dot.
- Move the activeView filter onto the main-worktree target only: the panel is
  an overlay above every view, so it must ack from the activity/tasks views too.
- Carry the owning worktree with each target instead of re-deriving it by tab
  id, and keep the first entry on a tab-id collision, so a duplicate id can no
  longer clear the wrong worktree's unread. Drops the `as string[]` cast.
- Re-scan on TOGGLE_FLOATING_TERMINAL_EVENT (next frame, after aria-hidden
  commits) since panel open/closed is React state the store never sees —
  opening onto an already-active completed tab now acks.
- Cover the new resolveAutoAckTabTargets helper, including the closed-panel
  regression asserted against selectFloatingWorkspaceHasUnread.

* fix(ui): re-scan floating workspace auto-ack on every panel-open path

The visibility gate read the panel's aria-hidden and only re-scanned on
TOGGLE_FLOATING_TERMINAL_EVENT, so the two paths that open the panel without
that event — the floatingWorkspace.maximize keybinding and the default
floating-button toggle — left an already-active completed tab's attention icon
lit. Drive the gate from the committed `enabled && open` state instead: that is
what aria-hidden is derived from, it covers every open path, and it drops the
requestAnimationFrame that existed only to outrun the un-committed DOM read.

Adds a hook-level test (happy-dom) that fails both when the gate is removed and
when the open re-scan is removed.

* fix(ui): re-read store state per auto-ack target

Acking the first target writes to the store and re-enters the scan
synchronously, so the pre-write snapshot could re-ack a target the
nested pass already handled. Idempotent today; a footgun for the next
non-idempotent action.

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-22 17:22:49 -07:00
Neil d07ce15cff refactor(host): route secret storage through a SecretStore port (#15916) 2026-08-22 16:38:00 -07:00
Jinjing 113f55c5f2 test(e2e): extract paired client window reveal into helper (#15991)
* test(e2e): extract paired client window reveal into helper

Paired clients launch hidden, parking runtime subscriptions. Playwright-driven
clients must be revealed to test actual user interactions. Extract the reveal
logic into a reusable helper with error handling and unit tests.

* test(e2e): handle crash dialogs and isolate collision fixture IDs

- Recover from recoverable UI error dialogs in selectRuntimeHost
- Give the same-ID collision fixture unique repo and worktree IDs to avoid
  reusing the runtime repo's ID, preventing fixture leakage
- Simplify verbose comments for clarity
2026-08-22 16:08:21 -07:00
Neil 4828c6bed4 UX: compact native density for context + dropdown menus (#15924) 2026-08-22 15:30:23 -07:00
Jinjing 02bee48e1d Retry transient ripgrep spawn failures instead of demanding install (#15983)
* retry transient ripgrep spawn failures instead of missing binary errors

Fork/exec pressure (EAGAIN, EMFILE, ENFILE, ENOMEM, ETXTBSY) should not
trigger ripgrep-not-found guidance. Add bounded retries (max 2x) for transient
spawn failures in Quick Open and file listing, respecting cancellation signals.
Introduce RipgrepLaunchFailureError to distinguish fork/exec pressure from
unavailable ripgrep installations.

* Handle cancellation during transient spawn failure retry window

When a query is cancelled after a transient ripgrep spawn failure but before
the retry decision resumes, the cancellation must be reported to the caller
rather than proceeding with a retry attempt.
2026-08-22 13:06:38 -07:00
JinjingandNeil 8d021c0b5a test(automations): assert in-place language switch on mounted picker (#15977)
* fix(automations): localize schedule weekday names and labels

The Weekly Day picker rendered a hardcoded English tuple, and shared
schedule labels built copy as `${day}s at ${time}` from an OS-locale
Intl weekday, so a non-English UI showed Sunday…Saturday (or 星期五s).

Shared now emits deterministic English (the CLI contract) plus a
locale-free AutomationScheduleDescriptor; the renderer formats that
descriptor through translate() with Intl/CLDR weekday names resolved
from getIntlLocale(). Fixes #14404.

* test(automations): assert localized weekday copy in rendered DOM

The existing coverage walked the React element tree, so nothing proved the
Day dropdown and cron status row reach the DOM localized. Mount the picker
under happy-dom with the Radix Select swapped for a native <select> (the
pattern RepositoryWorktreeDefaultsSection.test.tsx already uses, since Radix
portals its content only once opened) and read real option text.

Also key the weekday SelectItems by index rather than by translated copy, so
a runtime language switch reconciles instead of remounting all seven items.

* fix(automations): keep the weekday SelectItem key off the array index

react-doctor(no-array-index-as-key) rejects `key={index}`; the localized
weekday name is already unique per locale, so keep it as the key.

* fix(automations): match the real AutomationDraft shape in the render test

The fixture invented `repoId`/`branchMode`/`enabled` fields; runtime ignored
them but `tsc` did not. Mirror AutomationSchedulePicker.test.ts's fixture.

* fix(automations): localize the custom-cron field chips

The five cron field headers rendered one row above the status row this PR
localizes were still hardcoded English, so a Chinese UI showed
Minute/Hour/Day/Month/Weekday. Same defect shape as the deleted DAY_OPTIONS
array. Chip keys move to stable field ids so a locale that renders two fields
with the same word cannot collide, and the truncated header carries a title so
longer copy (es 'Dia de la semana') stays readable.

* fix(automations): keep weekday option keys stable

* test(automations): assert in-place language switch on mounted picker

Add a test that changes the language while the weekly picker remains mounted,
then checks that the localized labels update while the underlying values
("0"–"6") stay stable. This validates the regression-prevention that stable
index keys (added in #15884) support — without them, a locale change would
unmount and remount options, breaking the persisted dayOfWeek value.

Wrap the picker in a LanguageAwarePicker harness that calls useTranslation(),
mirroring the root-level subscription in main.tsx that drives real
re-renders on language change.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-22 11:25:19 -07:00
Jinjing 6c1286b592 Add Artifacts and Skills pages to navigation history (#15969)
* Add Artifacts and Skills pages to navigation history

- Record Artifacts and Skills visits in back/forward navigation like Automations
- Both pages properly rewind history when closed to the previous live entry
- Extract rewindHistoryIndexPastView() helper to deduplicate close-page logic across all page types
- Add test coverage for Artifacts/Skills navigation, separate entries, and shared link handling

* Add Artifacts and Skills pages to navigation history

Back/forward buttons now appear when navigating to Artifacts and
Skills pages, consistent with Terminal, Tasks, and Automations.
2026-08-22 11:00:01 -07:00
Jinjing 9ea1d28970 Fix Cmd+J Enter for worktree creation (#15970)
* fix(cmd-j): allow Enter to create worktree

* test: verify create dialog closes on Escape
2026-08-22 10:55:05 -07:00
NeilandJinjing 6e18c18e58 fix(automations): localize schedule weekday names and labels (#15884)
* fix(automations): localize schedule weekday names and labels

The Weekly Day picker rendered a hardcoded English tuple, and shared
schedule labels built copy as `${day}s at ${time}` from an OS-locale
Intl weekday, so a non-English UI showed Sunday…Saturday (or 星期五s).

Shared now emits deterministic English (the CLI contract) plus a
locale-free AutomationScheduleDescriptor; the renderer formats that
descriptor through translate() with Intl/CLDR weekday names resolved
from getIntlLocale(). Fixes #14404.

* test(automations): assert localized weekday copy in rendered DOM

The existing coverage walked the React element tree, so nothing proved the
Day dropdown and cron status row reach the DOM localized. Mount the picker
under happy-dom with the Radix Select swapped for a native <select> (the
pattern RepositoryWorktreeDefaultsSection.test.tsx already uses, since Radix
portals its content only once opened) and read real option text.

Also key the weekday SelectItems by index rather than by translated copy, so
a runtime language switch reconciles instead of remounting all seven items.

* fix(automations): keep the weekday SelectItem key off the array index

react-doctor(no-array-index-as-key) rejects `key={index}`; the localized
weekday name is already unique per locale, so keep it as the key.

* fix(automations): match the real AutomationDraft shape in the render test

The fixture invented `repoId`/`branchMode`/`enabled` fields; runtime ignored
them but `tsc` did not. Mirror AutomationSchedulePicker.test.ts's fixture.

* fix(automations): localize the custom-cron field chips

The five cron field headers rendered one row above the status row this PR
localizes were still hardcoded English, so a Chinese UI showed
Minute/Hour/Day/Month/Weekday. Same defect shape as the deleted DAY_OPTIONS
array. Chip keys move to stable field ids so a locale that renders two fields
with the same word cannot collide, and the truncated header carries a title so
longer copy (es 'Dia de la semana') stays readable.

* fix(automations): keep weekday option keys stable

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-22 10:53:55 -07:00
OrcaWinandm4air 72f896d677 feat(automations): navigate table search results with arrows (#15805)
* feat(automations): navigate search results with arrows

* Set overview tab for external automations on arrow selection

External automations lack a runs tab, so the detail pane must default to overview when navigating via arrow keys to keep the tab selection valid when the automation is later opened.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-08-22 10:15:44 -07:00
Jinjing 80b2a02377 Move worktree palette search to top of sidebar nav (#15854)
* Move worktree palette search to top of sidebar nav

The Cmd+J search button is now displayed as the first item in the
sidebar navigation, improving discoverability. Styling is simplified
to match the nav item layout with flex-based display and consistent
spacing.

* Test: add guard clause for worktree palette search button

- Add explicit type annotation for the search button querySelector
- Guard against missing button with clear error message
- Use direct property access now that button existence is verified
2026-08-22 10:08:00 -07:00
Jinjing 8bf1128a08 fix(mobile): remove close-tabs-to-right action (#15894) 2026-08-22 09:51:23 -07:00
Neil 5651662494 fix(wsl): migrate 21 call sites onto the WSL runner (#15923)
* fix(wsl): migrate 21 call sites onto the runner, after five review rounds

Rebased onto main now that the runner (#15903) has landed.

21 sites across 15 files move off ad-hoc `execFile('wsl.exe', ...)`. Allowlist
23 -> 16 on the WSL guard; 163 -> 152 on the W1 child_process guard, which moved
as a consequence.

Five review rounds, each finding real defects -- several introduced by the
previous round's fixes:

1. Hooks ran user orca.yaml scripts under dash; probe failure fell back to the
   login shell, reintroducing the ~/.profile stall the runner exists to remove.
2. An unparseable probe was cached permanently, disabling every WSL feature on
   the distro; hooks regressed from "runs degraded" to "fails".
3. Exit 127 had no expiry; a starved 5s probe hard-failed the 10s scan behind
   it; a joiner burned its budget on someone else's probe.
4. The comment stripper blanked live code, so the windowsHide guard walked past
   a real unguarded spawn and reported the file clean; an ownership-probe
   timeout silently deselected the user's Claude account.
5. Verification of the guards themselves.

The recurring finding -- a call answering "is this installed?" on a degraded
PATH -- was eventually fixed structurally rather than per-caller: the runner
refuses an unresolved guest PATH unless the caller opts in. Per-site vigilance
was demonstrably not holding; 3 of 8 sites had already forgotten the analogous
exit-code check.

Remaining 16 files need a runner mode that does not exist: a long-lived
streaming child (OAuth logins, hook relay), a synchronous caller, or a
host-level flag like --status that the guest-command API cannot express.

* fix(wsl): close round 5's P1s -- degrade where PATH was never needed

Round 5 measured the guards by re-executing their algorithms standalone rather
than reading them, and found four things.

P1 -- four skill/plugin paths gained a hard dependency on the login-shell probe
that they never had. They ran under a plain non-login `sh -c` on main, so a
probe failure now breaks WSL skill discovery and install on exactly the distro
the runner was built for: one with a slow `~/.profile`. Worse, the throw escapes
before each site's own error mapping, so the UI gets a raw internal string. They
degrade now, per the rule this branch already wrote down in
`wsl-fish-history-cleanup.ts`.

P1 -- Codex and Claude were asymmetric. Claude's five credential sites degrade;
Codex's were strict, so adding a WSL Codex account failed where adding a Claude
one succeeded. Three of the four are byte-equivalent to Claude sites, and their
scripts read `$HOME`/`$WSL_DISTRO_NAME`, which wsl.exe supplies without a login
shell. `assertWslCodexCliAvailable` stays strict on purpose -- that one really
does answer "is this installed?" (#9725).

P1 -- the ownership-probe timeout fix did not survive the rebase onto main. A
timeout still returned "not owned", which the caller *persists*, clearing the
user's account selection.

P1 -- `blankStringContents` desynced on a nested template literal
(`` `${`x`}` ``), leaving 116 lines of a child_process importer outside the
ratchet, with 27 importers structurally at risk. Now tracks template depth.
Regenerating against the fixed blanker: 70 -> 68 offenders.

Also: the windowsHide vacuity check could not fail while the allowlist alone
exceeded its bound -- the exact defect the sibling guard documents avoiding. It
now names a file that definitely offends.

* fix(wsl): close round 6 -- my blanker fix had traded a false positive for a miss

Round 6 re-derived the guard's answer from a TypeScript AST instead of trusting
the regex, and caught two things.

P1 -- the nested-template fix I shipped in round 5 introduced a worse bug than
the one it closed. Switching to "code mode" inside `${...}` without also
resetting the quote at a newline meant an apostrophe in a regex literal --
`` `'${value.replace(/'/g, "'\\''")}'` `` , which is exactly the shellQuote
shape all over this codebase -- inverted the lexer for the rest of the file.
`claude-accounts/service.ts` went blind from line 96, hiding a REAL unguarded
`spawn` at :1097: the WSL Claude managed-login path, which opens a console and
steals foreground on Windows. Round 5 traded one false positive for one false
negative and I did not notice, because the offender count went down.

The blanker now resets non-backtick quotes at a newline (the rule stripComments
already had) and tracks brace depth per interpolation. The spawn is fixed rather
than allowlisted, and the count is 69 -- the number the AST predicted.

P1 -- the ownership-timeout guard was dead code: it threw into its own `catch`
three lines below, which returned null, which the caller persists as "not owned"
and clears the user's account selection. Now a typed sentinel the catch rethrows.

P2 -- `WslGuestEnvironmentUnavailableError` reached the UI verbatim from the CLI
installer and the Codex availability check. Both mapped.

Method note: I had been regenerating the allowlist with a Python transcription
of the scanner, and the two drifted -- the same two-implementations problem this
workstream keeps finding. The allowlist is now generated by running the shipped
test with an empty list and taking what it reports.

* fix(guards): stop patching the lexer -- make the scanner fail closed instead

Round 7 proved my round-6 fix also did not work, by planting a plainly-named
unguarded `spawn` in `claude-accounts/service.ts` and watching the guard pass
3/3. That is three consecutive attempts at an exact lexer, each shipping a
desync that hid real calls, and each time the offender count went DOWN, which I
read as progress. Round 6's diagnosis was wrong too: the culprit is the
`templates` brace-depth stack, which nothing resets, not quote state.

So stop trying to be exact. `blankStringContentsDesynced` reports when the lexer
lost its bearings, and the guard treats that as an offender. Over-reporting is a
nuisance; under-reporting is a false clean, and a false clean is what let a real
console-flash spawn out of the ratchet twice. The allowlist goes 69 -> 82: the
13 extra are files whose scan cannot be trusted, now named rather than assumed
fine.

The planted violation is now caught.

Also from round 7:
- `SPAWN_CALL` missed promisified and renamed bindings, so `exec('where gemini')`
  (a real Windows cmd.exe spawn) and a detached `shell: true` in
  `cli/runtime/launch.ts` were invisible. Added execAsync/execFileAsync/
  execFileCb/spawnDetached.
- `BASHISM` matched `set -o pipefail` but not `set -euo pipefail`, which is the
  only spelling this tree uses -- so the check could not have caught the #14292
  signature it exists for. Fixed, and it immediately flagged a file; that one
  turned out to be a comment, so the bashism scan now strips comments too.
- The CLI installer error mapping my round-6 commit claimed was "both mapped"
  was never applied -- only the Codex side had been. Now actually mapped.

* fix(guards): close the four holes round 8 found by planting violations

Round 8 stopped reasoning about the guard and planted spawns into it. Four
holes, none of which reading had found:

- `windowsHide: false` **passed**. The check was `args.includes('windowsHide')`,
  a substring test. Now matches `windowsHide: true`.
- A ternary first argument was silently skipped: the method-declaration filter
  `/^\(\s*\w+\s*[:?]/` also matches `exec(useAlt ? 'a' : 'b', …)`. Now requires
  a type after the colon.
- Renamed bindings were not covered, despite the comment I wrote saying they
  were -- I had hardcoded three names. Aliases are now resolved from the import.

Each is verified closed by planting it and watching the guard fail.

`fork` is deliberately still unscanned. Round 8 is right that Node forwards the
option, but `ForkOptions` does not declare it, so the two live sites cannot be
fixed without a cast. Recorded in the verification doc rather than left as a
silent gap, along with two others worth knowing: the allowlist is file-granular,
so its ~18 false-positive entries carry a standing pre-approval for real
regressions in those files and cannot be retired by fixing code; and
`stripComments` has no desync report, so the fail-closed check is only half
applied.

The doc now also says how to verify a guard change: plant a violation. Every
guard fix here that was verified by reading was wrong.

* fix(wsl): stop preflight reporting installed CLIs as absent on a slow distro

Round 9's merge blocker, and the sharpest finding of the whole workstream: the
branch built to close #9725 had reopened it from the other side.

`preflight-wsl-command.ts` was one of five sites without
`allowDegradedEnvironment`, so a guest-PATH probe failure threw. Every consumer
collapses a throw into a verdict: `isCommandAvailable` and `isCommandOnPath`
catch to `false` ("not installed"), `isGhAuthenticated` and `isGlabAuthenticated`
read an empty payload as "not authenticated". So a slow distro made WSL git, gh
and glab read as missing.

Two things made it likely rather than theoretical. The probe took two thirds of
a 5s budget, leaving the command ~1667ms where main gave it the full 5s inside
its own login shell -- a cold WSL VM start routinely lands in that band. And a
probe timeout is cached for 30s with a re-probe threshold of 1.5x the failed
budget, which a 5s caller can never clear, so every preflight command
short-circuited without spawning wsl.exe at all -- and Re-check does not
invalidate the cache.

Fixes: preflight degrades instead of refusing, and the probe is capped at half
the caller's budget and at 4s, so no caller ends up with less time than it had
before the runner existed.

Also fixes a real console flash found on the way: `preflight-command-exec.ts`
spawns git/gh/node through `promisify(execFile)` with no `windowsHide`.

Round 9 also confirmed the credential paths are now *safer* than main: all 11
account sites degrade, every destructive guest operation is still marker-gated,
and main's `getOwnedManagedAuthPath` could disown an account on a 5s timeout --
which this branch turns into a failed launch instead of a destroyed selection.

* fix(wsl): make "Try again" able to succeed, and test the round-9 fix

Round 10 returned MERGE with one residual worth closing first.

A transient probe failure left the null-resolving promise in `inFlight`, so the
only way back was `retryAfter` -- and the 4s probe cap made the 1.5x budget
escape unreachable, because no caller can pass more than 4s. For the full 30s
window the four non-degrading sites returned their error *without spawning
wsl.exe at all*, and each of those errors says "Try again". The advice was
guaranteed to fail.

The entry is now dropped on a transient outcome and an explicit cooldown gate
replaces it, so the window alone decides. The window drops 30s -> 5s: long
enough to stop a stampede, short enough that the user's next click reaches a
distro that has since warmed up.

Round 10 also noted the round-9 fix shipped untested, which was fair. Added: the
probe-budget floor for 5s/8s/10s callers, and preflight's degrade opt-in plus
its stdout/stderr-carrying rejection, which isGhAuthenticated reads off the
caught error as an auth-success fallback.

* test(wsl): make the probe-budget guard actually guard

Round 11 caught that the regression test I added for the probe cap did not
bind: it seeded the guest environment, so the probe resolved in ~0ms and the
assertion read the command leg's timeout instead. Reverting the cap to the old
2/3 split left all three cases green.

Dropping the seed and asserting on the probe leg fixes it -- verified by
reverting the cap and watching all three fail.

A regression guard that cannot fail is the shape that has cost the most in this
workstream: the windowsHide guard silently passed a real unguarded spawn twice
for the same reason.
2026-08-22 05:45:21 -07:00
github-actions[bot] ac534abd67 Update README downloads badge 2026-08-22 12:27:31 +00:00
Neil 6b51ef4e2c feat(wsl): one runner for every wsl.exe invocation (#15903)
Five decisions have to be made on each `wsl.exe` call. Each has a right answer,
each is invisible in a diff, and each has shipped wrong:

- **Separator.** `--` makes wsl.exe expand `$name` in every forwarded argument
  before the guest runs -- even with no shell in the command -- so `awk
  '{print $2}'` loses its field reference (#12964).
- **Shell.** A login shell on a probe path sources `~/.profile`, so one blocking
  line eats the whole timeout (#14288) and every call pays startup (#9768). No
  login shell on a user-facing path means PATH does not match the user's own
  terminal, so nvm-installed agents read as absent (#9725, #7563, #8366).
- **Fencing.** An interactive login shell runs the distro rc, and stock Ubuntu
  writes its "run as administrator" hint to *stdout* -- so anything parsing that
  stream reads the banner as data (#11327, #11823).
- **WSLENV.** Unset, a Windows-side variable silently never crosses (#12557).
- **Payload.** Scripts go in on stdin. A script on stdin has no quoting boundary
  to escape from, which is what the base64 and `eval` wrappers work around
  (#14292). `filesystem-watcher-wsl.ts` already does this and is the only WSL
  caller with no quoting bug in its history.

`runWslProcess` makes them once, on top of W1's `runProcess` so it inherits
windowsHide, shell:false, timeouts and abort. `lane` is required with no
default: picking the wrong lane by omission is the most common WSL defect here.

The probe lane resolves the login PATH/HOME once per distro and then runs with
no shell at all, so #14288 and #9768 are closed by construction rather than by a
longer timeout. An unprobed distro degrades to the interactive lane -- "we could
not ask" must not become "run with no PATH".

Additive only: no call site is migrated yet. The new guard allowlists the 23
files that still spawn directly, and its length is the workstream's goalpost.

Two guard bugs found by testing the guards against planted call sites: a bare
`main/wsl` prefix also exempted `main/wsl.ts`, `wsl-availability.ts` and
`wsl-unc-delete.ts` -- three real offenders.
2026-08-22 02:06:06 -07:00
Neil 990b23611e fix(i18n): correct five Korean strings that changed meaning in machine translation (#15875) 2026-08-22 01:40:08 -07:00