Commit Graph
1237 Commits
Author SHA1 Message Date
2d23217166 feat: add Trae CLI as a supported TUI agent (#10763)
* feat: [AI-GEN] add Trae CLI as a supported TUI agent

Closes #10579.

Wire trae-cli into the desktop and mobile agent catalogs following the
same integration pattern as other CLI agents (e.g. Ante, Devin):

- src/shared/types.ts, tui-agent-config.ts: register 'trae' with
  detectCmdAliases (traecli/trae-agent) and argv prompt injection,
  matching trae-cli's `trae-cli [prompt]` contract. The CLI's own
  third documented alias `ta` is intentionally excluded — too generic
  a 2-letter name to use as a PATH-existence detection signal without
  false-positiving on unrelated tools.
- src/shared/trae-headless-command.ts: recognize `--print`/`-p` and
  `--output-format json|stream-json` as one-shot headless invocations
  (same shape as claude-headless-command.ts) so they aren't mistaken
  for a live interactive session.
- agent-kind.ts, telemetry-events.ts, agent-status-types.ts,
  agent-type-label.ts, tui-agent-display-names.ts,
  tui-agent-permissions.ts (YOLO via trae-cli's own --yolo flag),
  tui-agent-selection.ts: standard per-agent registrations.
- agent-catalog.tsx, agent-favicon-assets.ts,
  mobile/src/tasks/mobile-tui-agents.ts,
  mobile/src/components/mobile-agent-icon-assets.ts: catalog entries
  and bundled favicon (fetched from docs.trae.cn, required by mobile's
  offline-icon invariant test).
- i18n: add the "Trae" label to all five locale catalogs (en/es/ja/ko/zh).
- Tests: agent-process-recognition, agent-status, tui-agent-startup.

Verified with `pnpm typecheck` (desktop + mobile), the relevant vitest
suites (869 tests across 12 files, all green), oxlint (clean), and a
real end-to-end launch of the actual trae-cli binary through Orca's
pty.spawn IPC path (confirmed via the OS process table).

* fix: [AI-GEN] point Trae catalog entry at the real CLI quick-start doc

docs.trae.cn/cli (what the installed CLI's own --help text prints as
its "User manual" link) soft-404s — the docs site restructured and the
working page is docs.trae.cn/cli_get-started-with-trae-cli (confirmed
by HTTP fetch: real page title "TRAE CLI 快速开始" vs the old path's
"404 - 页面不存在"). Addresses CodeRabbit's homepageUrl review comment.

* fix: [AI-GEN] detect Trae on traecli, not the ambiguous trae-cli name

Per @AmethystLiang's review: the open-source bytedance/trae-agent
project (MIT, ~12k stars) registers its own console script as
`trae-cli` (pyproject.toml: `trae-cli = "trae_agent.cli:main"`), an
entirely unrelated CLI with a different contract (`trae-cli run
"task"`, `-p` short for `--provider`). Detecting on bare `trae-cli`
would false-positive on that project's installs and break launch for
anyone who has it instead of the actual TRAE CN CLI.

- tui-agent-config.ts: detectCmd/launchCmd/expectedProcess -> `traecli`
  (TRAE CN's own installer symlinks this alias too, but the other
  project does not ship it). Dropped the `trae-agent` alias entirely —
  it's the colliding project's literal repo name, the highest
  false-positive string available.
- agent-catalog.tsx: cmd -> `traecli` to match; faviconDomain ->
  `www.trae.cn` (bare `trae.cn` 404s on Google's favicon service;
  `www.trae.cn` is the product-root domain that actually resolves).
- mobile-tui-agents.ts: faviconDomain -> `www.trae.cn` to match.
- Tests updated: agent-process-recognition now asserts `trae-cli` and
  `trae-agent` are NOT recognized as Trae (regression guard against
  reintroducing the collision); tui-agent-startup updated for the new
  launch command.

promptInjectionMode stays `argv` and the headless-command file stays
as-is — both verified against the real TRAE CN CLI's actual --help
output (pasted in the PR review thread), not assumptions.

* refactor: [AI-GEN] share one print-mode headless matcher across agents

trae-headless-command.ts was a rename-only fork of claude-headless-command.ts,
and ante-headless-command.ts carried a third copy of optionName. Collapse both
print-mode files into print-mode-headless-command.ts, dispatch from a
Partial<Record<TuiAgent, ...>> table instead of an if-chain, and compress the
Trae comments to the repo's one-line style.

* fix: [AI-GEN] terminate Trae flag parsing before the positional prompt

`traecli` is a Cobra CLI with subcommands, so an argv prompt starting with
`help`, `config`, `-…` was dispatched as a subcommand or flag instead of being
run as the task. Add `argvPromptSeparator: '--'` (same reason Grok has it), and
stop the shared print-mode headless matcher at `--` so a prompt that reads like
`--print` no longer drops the pane out of agent recognition.

* docs: [AI-GEN] name both Trae CLIs explicitly in the detect-name comment

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

* docs: [AI-GEN] drop the vendor tag from the Trae union comment

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

* fix: [AI-GEN] guard the nullable startup plan in the Trae separator test

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

---------

Co-authored-by: 陈泽榜 <chenzebang@jianzhikeji.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-28 11:18:49 -07:00
Jinjing 1bd80931bd fix(diff): stop file-tree navigation remounting combined diffs; make tree resizable (#11088) 2026-07-28 10:53:59 -07:00
Jinjing 21dee21a6d test(cli): lock CLI-compatible timeout parse contract (#11206)
parsePositiveSafeIntegerNumericText mirrors the CLI's own Number()
coercion on purpose: text like `600000.000000000000001` is the budget
the CLI will actually wait on, so rejecting it here would leave the
relay and SSH kill timers shorter than the CLI's and cut the request
short. Document that and pin it with regression cases.
2026-07-28 10:22:33 -07:00
Neil de162c632b fix(memory): retune image and orca.yaml ceilings that rejected valid input (#10815) 2026-07-28 01:51:22 -07:00
OrcaWinandOrcaWin 77d4c64f7a Improve orchestration migration safety for live legacy workers (#11107)
* fix(orchestration): clarify legacy migration safety

* fix(cli): sanitize legacy formatted messages

* test(runtime): allow near-cap fuzz under shard load

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 00:36:25 -07:00
Brennan Benson 48e31b3fc0 fix(agent-status): show Codex v2 subagents (#11059)
* fix(agent-status): track Codex rollout subagents

* fix(agent-status): resolve cross-day Codex child rollouts and unblock CI gate

Codex files each rollout under its own local start date, so a session that
runs past midnight spawns children into a sibling day directory. Scanning
only the parent's directory left 13% of real subagent spawns (48/371 across
local rollouts) permanently unresolved, which pinned a phantom "working" row
and re-ran readdirSync every poll tick forever. Resolve the child's own day
directory from occurred_at_ms, and time-box a child whose rollout stays
unreadable so a deleted or never-written file can't leak a working row.

Also make the hook HTTP handler return void: the changed-code quality gate
keys findings by span overlap, so this PR's added line inside the pre-existing
async createServer callback resurfaced no-misused-promises as a new finding.

Tests cover cross-day resolution, grace-period retirement, and that the poll
re-arms across successive roster changes (the prior tests passed even when
the poll died after its first change).

* fix(agent-status): keep the Codex subagent poll alive across nested hooks

A nested non-codex CLI inherits its parent's ORCA_PANE_KEY, so its hook
POST reached scheduleCodexSubagentPoll and tore the timer down before the
source guard, silently ending polling while a rollout child was still live.
2026-07-27 23:50:25 -07:00
Neil c25d85cc4c perf(terminal): eliminate adverse control and frame-gate cases (#11045)
* perf(terminal): eliminate dense control and frame gate regressions

* test(terminal): keep gate labels in valid expect shape

* test(terminal): expose the surviving sub-threshold control-density case

The only adverse strip fixture sat at 50% control density, which is exactly
where the fallback fires and wins. A shape at 31 controls per 64-unit block
evades the trigger and still loses to the per-character legacy (0.67x), so the
benchmark structurally could not show it.

Add that fixture, pin both density literals in the staleness guard so a retune
fails loudly instead of silently measuring a boundary that moved, and export
the probe constant the equivalence test was hardcoding.
2026-07-27 23:28:05 -07:00
Neil 038fd7a50c feat(workspaces): derive readable emoji identifiers 2026-07-27 22:11:56 -07:00
Neil badf91101b fix(quality): enforce performance-safe lint baseline (#11074)
* fix(quality): clear safe existing lint findings

* fix(quality): keep lint cleanup allocation-free

* fix(quality): enforce performance-safe baseline

* test(terminal): drain deferred confirmation cleanup
2026-07-27 20:54:02 -07:00
Brennan Benson ee7ec43149 fix(codex): keep a host account switch inside the host lane (#10992)
* fix(codex): keep a host account switch inside the host lane

markLiveCodexSessionsForRestart walked every tab's PTYs and carded any pane
whose foreground looked like Codex. There was no lane check anywhere in that
path, so a host account switch raised a restart notice on live SSH/relay panes
— and a notice mutes the pane, so the user's remote terminal went deaf.

The notice was provably spurious: a remote spawn carries a connectionId, so
isDaemonHostSpawn is false and no CODEX_HOME is ever injected. The remote Codex
uses the remote machine's own credentials; a local selection cannot reach it.

Scope marking by lane instead. A pane's lane is (machine, runtime): `host`,
`wsl:<distro>`, `env:<id>` for a relay environment, or an SSH connection that
no managed selection can name. A switch made while a runtime environment is
active still cards that environment's panes, which is the case that made the
old "mark everything" behaviour look right.

WSL was the same defect, not a separate one. A Windows run saw a WSL pane
correctly escape a host switch, but only because its foreground read `wsl.exe`,
which fails the Codex-foreground test — the Win32 process table cannot see into
a WSL2 VM. That is incidental: `codex`, `node` and `python3` foregrounds are all
eligible today, so a WSL pane that surfaces one (WSL1 pico-processes are in
Win32_Process) would be carded by a host switch. The lane is now what decides.

Also stop queueing remote and SSH panes into the bind-driven stale sweep at all.
recordCodexPaneAccountForSpawn bails on anything that is not a daemon host
spawn, so listStalePanes can never report one stale, yet each pane still spent
every rung on a 15s-timeout remote RPC — ~75s per pane since the ladder widened
to five rungs.

The lane vocabulary moves to shared/ so the renderer keys panes exactly as a
launch does rather than growing a third copy of the rules.

Refs #10757

* fix(codex): key a WSL pane by the distro its launch actually used

The lane guard derived a pane's WSL distro from the workspace UNC path alone.
A launch does not: pty.ts hands getCodexSelectionTargetForPty a third argument,
the resolved runtime's distro, so a wsl.exe pane on an ordinary Windows-path
worktree launches under `wsl:Ubuntu`. The renderer keyed that same pane
`wsl:__default__`, so the Ubuntu switch never reached it — the pane kept the old
account with no notice, which is #10757 returning by a new route on the exact
platform the issue was reported from.

Resolve the distro the way the spawn does: the project execution runtime first,
then terminalWindowsWslDistro. Both are already in renderer state.

Also match a distro-less WSL switch against the whole `wsl:` family. Two
mutations reach the renderer as `{runtime:'wsl', wslDistro:null}` while writing
concrete distro slots: selecting the system default clears EVERY wsl slot
(setSelectedCodexAccountIdForTarget), and `add` stores the distro it discovered
from the machine. Keying those to `__default__` missed the very panes they
re-pointed. The residual cost is over-marking a sibling distro after an add,
bounded to this machine's WSL panes and far cheaper than a stranded pane.

An owner-less remote pane colliding with the host lane was untested — that
collision is what would mute a working remote terminal, so pin the disjointness
rather than the literal key.

Refs #10757

* fix(codex): resolve a pane's lane the way its launch resolved it

Three more places where the renderer's lane and the launch's lane disagreed.
Each disagreement is silent: too narrow and a stranded pane never gets its
notice (#10757 returns), too wide and a healthy pane is muted, because a notice
makes onData drop every keystroke.

Shell: main runs the request through resolveLocalWindowsTerminalRuntimeOptions,
so an unset shellOverride still lands on WSL when that is the Windows default.
Reading tab.shellOverride alone called such a pane `host` — a host switch would
have muted a working WSL terminal. Gate on the renderer platform, as pty.ts
gates on process.platform.

Cwd: a terminal's startup cwd is deliberately not constrained to the worktree
(resolveTerminalStartupCwd, #7685), and main keys the lane off that cwd. Follow
it through the same shared call instead of reading the workspace root, so a pane
split after `cd \\wsl.localhost\...` is keyed where it actually runs. The comment
claiming a pane can never start outside its workspace was simply wrong.

Family match: narrow the previous commit. setSelectedCodexAccountIdForTarget
only nulls every WSL slot when the account is null AND no distro is named; any
other write lands in one slot. So claim the family only when the change actually
cleared them all, and let `add` pass the created account's concrete target
rather than the row's "WSL default". Both call sites already knew which case
they were in.

Refs #10757

* fix(codex): derive the pane's project runtime the way main does

The previous commit reached for getLocalProjectExecutionRuntimeContext as a
stand-in for main's resolveLocalProjectRuntimeForWorktreeId. They are not the
same function, and the differences both produce wrong lanes:

- It falls back to `state.activeRepoId` when the worktree is not a git worktree,
  so a folder-workspace pane inherited whichever repo happened to be selected.
  That is not a property of the pane at all — the lane moved when the sidebar
  selection moved. On a WSL project it both muted a healthy host pane and hid
  the notice a host switch owed it.
- It synthesizes a runtime from `inherit-global` where main returns undefined,
  and its host branch rewrites an explicit `wsl.exe` to powershell.exe, keying a
  live WSL pane `host`.

Walk repo -> project directly instead, which is what resolveLocalProjectRuntimeForRepo
does, and use it only to supply a distro — never to downgrade a shell. That also
drops the throwing call out of this path entirely; the lane runs outside
scanCodexPanes' inspection guard, so a throw there would have lost the notice
for every pane in the batch, not just one.

Also find the added account by diffing the roster. Reading it back through the
row's active id returns null once two distro slots are filled, which sent the
notice to `wsl:__default__` while `add` had written a concrete distro.

Refs #10757

* fix(codex): key the lane off the runtime the renderer actually shipped

Reverses the project-runtime half of the previous commit. That commit assumed
main resolved the project runtime itself, so it re-derived one by hand. It does
not: for a local pane the RENDERER computes it with
getLocalProjectExecutionRuntimeContext and ships it with the spawn
(pty-connection.ts), and pty.ts feeds that straight to getCodexSelectionTargetForPty.
So the helper is not an approximation to be improved on — it is the launch.

The hand walk dropped the global Windows runtime default, which is what turns an
`inherit-global` project preference into WSL. A user who set their runtime
default to WSL but left terminalWindowsShell alone would have had every live WSL
pane keyed `host`: muted by a host switch, and missed by their own. It also
disagreed on folder workspaces, where the launch really does resolve through the
active repo.

Keep the repair-required early return: that call throws, and it sits outside the
scan's per-pane failure guard, so a throw would lose the notice for every pane in
the batch rather than one.

Separately, floating terminals have no workspace root, so their startup cwd is
used verbatim (resolveTerminalStartupCwdForWorkspace). Resolving one against a
root that does not exist yielded no cwd at all, keying a floating Codex pane on
a WSL filesystem as `host`. Read its cwd directly.

Require exactly one new account before trusting the roster diff — an unloaded
prior roster makes every account look new, and Add Account is not gated on it.

Refs #10757

* fix(codex): stop claiming a floating-terminal cwd the tab never has

The floating-terminal branch read tab.startupCwd, which no floating creation
path ever sets (FloatingTerminalPanel, FloatingTerminalWindowControls,
floating-workspace-tab-creation all pass none). Its cwd is resolved over IPC
from settings.floatingTerminalCwd and handed to the transport as a prop, so it
never reaches the store at all. The branch was inert and its comment described
main's handling of args.cwd rather than what the code read.

Say what is actually true: a floating pane is keyed by its shell, and the
configured-WSL-cwd-under-a-host-shell case is a known gap. Guessing from the
unresolved setting would risk the mute direction, which is the expensive one.

Also pin the repair-required early return. resolveLocalWindowsTerminalRuntimeOptions
throws there, and the lane runs outside scanCodexPanes' per-pane failure guard,
so without it Promise.all rejects and every pane in the batch loses its notice.
That guard had no coverage; removing it now fails with the spawn error.

Refs #10757

* fix(codex): trust the lane main recorded at spawn over a re-derived one

The switch path re-derived each pane's Codex lane from current state while
main had already written the resolved shell, cwd and distro at spawn. Four
review rounds each found another divergence between the two, and the
derivation still answers for a launch that never happened once the user
edits a runtime preference.

Prefer the recorded lane where one exists; keep the derivation for the panes
main never records — pre-feature panes, LocalPtyProvider spawns and remote
ids — and log when the two disagree.

* refactor(codex): drop a redundant guard around the recorded-lane lookup
2026-07-27 19:47:07 -07:00
Hansss 2cf91b8e69 fix(skills): complete plugin-cache scans without false attention (#10865)
Fixes the P0 where skill cards showed an unclearable amber "Needs attention"
while the Details dialog reported everything up to date.

Root cause: when the plugin-cache scan tripped one of its own bounds it recorded
an incomplete path, and inventorySkillFreshness expanded that into one fabricated
placement per manifest skill at a path it never stat'ed. Those synthetic
"inaccessible" copies lit the pill, were filtered out of the dialog, and could
never be cleared because plugin-cache is not an updatable topology.

- Removes the fabrication; reports typed scan issues instead.
- Requires readable SKILL.md evidence before promoting a directory to a candidate,
  so a same-named foreign plugin (Codex's own computer-use) no longer flags.
- Prunes skill payloads and node_modules so ordinary vendor caches stop tripping
  the depth and entry bounds.
- Partitions scan reasons: only a real read failure raises a pill; bounds that
  ended the walk block an all-clear claim; the rest are Details-only.

Fixes #10633. Refs #10659, #10904, #10918, #10775, #10791, #10813.
2026-07-27 19:11:57 -07:00
Jinjing 9a8e21a47e fix(workspace-space): bound traversal memory and serialize local disk scans (#11026)
* fix(workspace-space): serialize local disk and cap traversal memory

Prevent resource exhaustion during large workspace scans by limiting
local disk access to one concurrent `du` call and capping portable
traversal memory to 100k entries or 64 MiB per worktree. Fixes July 27
incident with 298 worktrees causing host stalls and renderer OOM.

Portable traversals now use fixed-worker iterative frames instead of
recursive promises. Capacity failures become unavailable rows. Behavior
below limits is unchanged.

* fix(workspace-space): bound concurrent SSH fallback traversals

Desktop-side SSH fallback traversals run in the main process with independent admission
budgets. Without limiting, up to six concurrent traversals could stack six 64 MiB budgets.
Cap remote fallback traversals to 2 concurrent, keeping aggregate admission at 2 × 64 MiB.

Also make capacity error messages reflect configured limits instead of hardcoded defaults.
2026-07-27 18:50:52 -07:00
Brennan Benson 4340781c9f fix(codex): drop the resume argv when session provenance is unverifiable (#10805)
Closes #10793.

When Orca could not verify the originating Codex session file it either threw — a
red per-pane toast and a failed spawn, reported as constant spam on #10757 — or
returned null. Returning null did NOT start a fresh session: the renderer had
already baked ['codex','resume',<id>] into the command and pty.ts never rewrote
it, so CODEX_HOME simply fell through to whichever account was selected.

The resume argv is now dropped so a plain `codex` launches, with a banner telling
the user. The invariant — never run `codex resume <id>` under an account that does
not own that rollout — is now satisfied by construction rather than by refusing to
spawn. A verified resume is unchanged and still pins CODEX_HOME to the
originating home.

Reviewed over two adversarial rounds; seven defects found and fixed, including a
HIGH where local-provider (non-daemon) spawns still carried
ORCA_SEQUENCED_STARTUP_COMMAND with `resume <id>` — the wrong account behind a
banner claiming it started fresh. `env` is now declared after the strip so no
point in the handler can reach the pre-strip value.

Live-validated in a real Orca dev build: all five cases proven on the SPAWNED
PROCESS, including a real rollout under an untrusted home (the only shape that
discriminates) and the local-provider path forced by stopping the daemon.

An earlier CI failure on multi-client-navigation-isolation.integration.test.ts was
investigated and is a PRE-EXISTING flake — a ~4ms race in the session-tabs notify
coalescer that fails 5-8/24 on clean main, more often than on this branch. Fixed
separately in #11022.

Not verified: no Windows execution — its POSIX-only tests skip there and the
#10757 reporter is on Windows. SSH is partial: no spurious banner or drop observed
against a real target, but headless spawn does not deliver startup commands so the
remote argv could not be read. The relay/mobile notice channel deliberately has no
banner; the argv drop does happen there, so the invariant holds.
2026-07-27 18:06:30 -07:00
Brennan Benson 0956d5ca3a feat(skills): run skill updates in the background without a terminal (#10843)
* feat(skills): run skill updates in the background without a terminal

The Update skills dialog had no primary action at all — its footer was only
Re-check and Close, and the real action was a pre-filled command in an embedded
PTY that the user had to press Enter on. Orca already builds and validates that
command, so it now runs it.

- Add a headless runner for `npx --yes skills update <names> --global -y`. Both
  --yes flags are load-bearing: npx's skips the package-install prompt, and the
  skills CLI's takes its own non-interactive branch. stdin is ignored so
  `process.stdin.isTTY` stays falsy, which is the other half of that gate.
- Own the run in main so closing the dialog backgrounds it instead of killing
  it, and surface it in the status bar: spinner while running, a green check on
  success that clears itself, and a failure that persists until acted on.
- Derive per-skill outcomes by re-scanning the freshness inventory after exit
  rather than parsing stdout. `skills update` has no --json (that flag exists
  only on `list`) and reports progress per-source, not per-skill, so the run
  bar is deliberately indeterminate instead of faking a percentage. When the
  re-scan has a verdict it outranks the exit code.
- Drop the version trail from the rows and surface the skill list and skip
  reasons directly instead of hiding them behind a disclosure.

Also fixes a width bug the collapsed disclosure used to hide: deep plugin-cache
paths set the dialog's width and pushed the footer actions off-screen.

* refactor(skills): use one row component across every update state

The ready and running views were separate components with different row
shapes, so pressing Update swapped the dialog's body for a different layout.
They are now the same `SkillUpdateRow` instances throughout — only the status
slot's contents change — and a test asserts the row is literally the same DOM
node from "update available" through pending to the result.

- Collapse each skill's locations behind its own disclosure. A skill with
  several plugin-cache copies was dumping every path inline and burying the
  actions; the row now shows a location count and expands on demand.
- Put status in a single slot between the name and the count rather than a
  leading icon column. A leading icon has nothing to show in the resting state
  and reserving its box just indented every name past an empty gap.
- Pin the running/finished run's names in `groupSkillFreshness` so a successful
  update doesn't drop its own rows the instant the re-scan lands.

`skill-freshness-group.tsx` becomes `skill-location-chip-copy.ts` — only its
chip label/tooltip helpers survived, and it no longer holds JSX.

* fix(skills): place the status glyph left of the skill name

Review feedback on the row header: the badge belongs immediately right of the
name so it reads as part of it, and the run's status circle/check belongs to
the left of the name rather than sharing the badge's slot on the far right.

Name, glyph and badge are now one left-aligned group; the location count and
chevron stay right-aligned. `available` still has no leading glyph — an empty
reserved box only indents the name past a gap with nothing in it.

* fix(skills): correct the headless update run's verdict, cancel path, and stopping copy

Review fixes for the headless skill-update runner.

Main process:
- Judge per-skill outcomes on a positive signal. "Absent from
  eligibleUpdateNames" is not success: a deleted, half-written, or unreadable
  skill also leaves that list, so a corrupt update reported a green check.
  skillUpdateFailedNames now requires every convergent placement to come back
  current or newer-known.
- Retire a child's handlers with a per-run token. A failed spawn emits error
  *and* close, so the second settle clobbered the real spawn ENOENT; a
  cancelled child could also settle, or write output into, the run that
  replaced it. The token guards the rescan's finish closure too.
- Hold the run `running` until the killed process tree is actually dead.
  Releasing on the synchronous path let an immediate re-Update spawn a second
  npx writing the same bundles, with a watchdog so a sweep that never settles
  cannot wedge the run.
- Kill the tree, not just the npx wrapper, via killWithDescendantSweep.
- Publish an error instead of a silent `started: false` when the cmd.exe rail
  rejects the resolved npx path, which a profile directory containing & or %
  is enough to trigger on Windows.
- Coalesce captured output into one push per tick instead of structured-cloning
  the whole buffer to every window on each progress frame.

Renderer:
- Keep rows on screen while the settling re-scan runs. Refreshing the inventory
  nulls it synchronously, so every row vanished at the moment the result
  appeared. Rows render off the last good scan; eligibility stays on the live
  snapshot so nothing is authorized off stale bytes.
- Retry the names that failed, not the eligibility list that same re-scan has
  just emptied.
- Add Stop, restoring the escape hatch the embedded terminal used to provide,
  and say "stopping" on every surface rather than claiming the update keeps
  running in the background.
- Show a skipped skill's reason outside the disclosure, so it no longer depends
  on a mount-time defaultOpen a later re-scan can never re-fire.
- Drop the summary line telling users to open "Update details", a control this
  PR removes; it was translated into four languages.
- Keep the success linger from retiring a result the open dialog is showing.
- Delete skill-location-chip-copy.tsx: an unreferenced copy of the old row
  component, colliding on basename with the module that is actually imported.

* fix(skills): divide update list from summary
2026-07-27 17:43:17 -07:00
NeilandOrca 2dac0741b4 fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew (#10817)
* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs #9993

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

* fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse

The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.

* fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact

The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — #9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).

* fix(daemon): refuse 2031 authority from a daemon that cannot retract it

Round-2 review found a wire-compatibility hole in the original #9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- #9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.

* fix(daemon): gate background delegation, not just the fact stream

A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

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

* fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background

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

* fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.

* fix(terminal): preserve mode 2031 reply decisions

* fix(build): record daemon protocol v29 compatibility

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:47 -07:00
NeilandOrca a49d68f8c2 perf(git): overlap getBranchCompare's head-of-chain reads (#10895)
* perf(git): overlap getBranchCompare's head-of-chain reads

Four git spawns ran strictly in series before any compare work began:
branch --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>.

Three are independent -- compareRef is display-only metadata and HEAD's oid does
not depend on the base ref -- so they now run concurrently. The fourth was
redundant outright: the probe already runs `rev-parse --verify --quiet
<ref>^{commit}` and discarded the oid it printed, which was then re-resolved by a
second spawn. resolveWorktreeBaseCommitOid returns that oid so it can be reused;
hasWorktreeBaseCommitRef now delegates to it, leaving its other 4 callers
untouched.

3.6-3.7x on a short remote base label (192ms -> 52ms), 1.44x on an
already-qualified refs/... base, which skips the probe by design.

Reuse is keyed by ref: resolveWorktreeAddBaseRef returns at its first successful
candidate, so only that ref's oid is ever read back. Peeling is safe because only
refs/heads and refs/remotes candidates reach the probe, where ^{commit} is a
no-op.

No new git features: this removes a spawn rather than adopting an option.

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

* fix(git): preserve compare semantics across providers

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:03 -07:00
Brennan Benson 025c242f0c feat(dashboard): tint agent cards by state, show the project as an icon, name the chat (#11012)
* feat(dashboard): tint agent cards by state, show the project as an icon, name the chat

Three glanceability changes to the agent board:

- The "Needs You" signal moves from the column border onto the cards
  themselves, and done agents get the same treatment in green. Idle cards
  that simply aren't running stay neutral, so a tint always means "this one
  wants you".
- The repo is now its own icon with the name in a tooltip, instead of a
  mono label that truncated and competed with the worktree name. Icons ride
  the snapshot keyed by repoId — image icons are data URLs, and the
  snapshot republishes several times a second.
- The user-message line is labelled with the tab's conversation name rather
  than "You", resolved through the same getAgentRowConversationName the
  sidebar's agent rows use. Status-only titles still fall back to "You".

* refactor(dashboard): head the card with the session name, move the worktree beside the project

The conversation name now sits next to the agent icon as the card's heading
rather than prefixing the user-message line, and the worktree drops to the
footer beside the project icon. The message line reads "You" again — the name
moved up, so keeping it there said the same thing twice.

Cards without a resolvable session name keep the worktree as the heading, and
the footer omits it rather than repeating it.

* fix(dashboard): thread settings into every snapshot builder caller

Adding `settings` to DashboardSnapshotState left three callers constructing it
without one. The in-window drawer's was a real defect, not just a type error:
useLiveDashboardSnapshot derives its own snapshot rather than receiving the
relayed one, so a dropped slice silently blanks generated conversation names
in the drawer while the pop-out shows them.

Bucket counts pass null deliberately — they never render a conversation name,
so the sidebar stays unsubscribed from settings.

Covers the drawer's wiring with a test, since `settings: null` type-checks and
would blank names again without failing loudly.

* test(dashboard): complete the terminal layout fixture

TerminalLayoutSnapshot requires expandedLeafId; the neighbouring builder test
hides this behind an `as unknown as` cast on the whole state object.
2026-07-27 17:13:23 -07:00
NeilandOrca 7f3c95a585 fix(git-history): stop reading the option marker as the resolved ref name (#10906)
`rev-parse --verify` swallows --end-of-options, but --symbolic-full-name
deliberately echoes it -- on every git version tested, 2.25 through 2.49:

  $ git rev-parse --symbolic-full-name --end-of-options feature
  --end-of-options
  refs/heads/feature

resolveSymbolicFullName took the first non-empty line, so it returned the literal
string "--end-of-options" instead of the ref. That value flows into
gitHistoryRefFromFullName, matches none of the refs/heads, refs/remotes, or
refs/tags prefixes, and every named branch and tag in git history was silently
categorized as a plain commit with a garbage id.

Skip the marker line. Version-independent bug; no test covered it.

Also pins git's echo behavior in the real-binary compatibility suite, so if a
future git stops emitting the marker the reason for the skip gets re-read rather
than the assumption quietly rotting.

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:40:56 -07:00
+21 b31e66ed48 fix(browser): survive a transient Windows lock during cookie import (#10697)
* fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy

The snapshot attempt loop only reacts to a `false` return, so a throwing
copy escaped it entirely. On Windows, AV/EDR briefly opens a file literally
named "Cookies" with FILE_SHARE_NONE, which turns an otherwise-fine
copyFileSync into `EBUSY errno -4082 syscall=copyfile` and aborts the whole
import (#9355).

Route the main-database copy through the existing
`copyFileWithWindowsRetry`, already used for the same AV window in #1507.
It is a no-op off Windows (maxAttempts=1), so POSIX still fails fast and
ENOENT is never retried on any platform.

* fix(browser): degrade the cookie-import staging DB instead of aborting the import

Staging exists only to back the cold-restart replay for cookies the
in-memory path rejects, but three points in it were fatal to the whole
import (#9355):

- the staging copy from the live partition DB — also a file named
  "Cookies", so the same AV/EDR handle blocks it;
- opening/PRAGMA-ing that staged file;
- both were unguarded, so a throw escaped to the catch-all and returned
  `ok: false` even when every cookie could load in memory.

Each is now non-fatal and diagnosed. Two invariants keep the degraded path
honest: `imported++` moved out of the staging insert so the summary counts
importable cookies rather than staged rows, and `setPendingCookieImport` is
never called when staging is unavailable — registering a path that was never
written would replay a missing or partial DB over the live partition on cold
start.

* fix(browser): stop a degraded cookie import from replaying a stale staged database

Review round 1 found the staging-degradation path could leave an older
pendingCookieImports entry registered while the import rewrote the live
session, so the next cold start replayed the stale DB over fresh cookies.

- add clearPendingCookieImport so a degraded import retires the old entry
- degrade staging on BEGIN/insert/COMMIT failure instead of aborting the import
- discard the staged cookie copy on every non-registering path
- pin the stagingAvailable guard, which previously survived mutation

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

* fix(browser): report a degraded cookie import honestly instead of as a clean success

Making the staging failure non-fatal introduced a silent-loss path: the import
clears the live jar before loading cookies, so when staging was unavailable AND
Electron rejected cookies, the user lost their old jar, got none of the new
cookies, and still saw "Imported N cookies".

Adds an optional `warning` to the import summary, set only on that degraded
branch, and routes every cookie-import toast through a shared emitter that
raises a warning toast instead of an unqualified success.

Also closes three test holes found in review:
- clearPendingCookieImport had no direct tests; deleting the wrong partition key
  survived all 10 registry persistence tests. Now covered, mutation killed.
- The staging-insert-failure test was vacuous (memoryFailed === 0 suppressed
  registration on its own). It now forces a memory failure.
- No test pinned the success-path clear; removing it survived. Now covered.

* fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361)

* fix(github): resolve owner/repo through SSH Host aliases (#10284)

Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.

* fix(github): harden SSH alias resolution

* Update README downloads badge

* fix(persistence): fsync state writes so a rename is actually durable (#10631)

* fix(persistence): fsync state writes so a rename is actually durable

`Store` wrote `orca-data.json` to a temp file and renamed it. rename() is
atomic for readers but says nothing about durability: without an fsync the
directory entry can reach disk before the data does. After power loss or a
hard crash the file can come back holding the previous state or, worse,
zero bytes — and `JSON.parse('')` throws, so an empty file takes the
full corrupt-file path rather than degrading.

This is the same empty-file symptom as #1158 from a different cause. That
issue fixed a logic path that persisted empty state and added the .bak ring
as a safety net; the ring also catches this, which is why it went unnoticed.
Recovery costs up to an hour of tabs/layouts/session state (backups are
throttled to >=1h spacing), and a user in their first hour has no backup
slot yet, so they land on defaults indistinguishable from a fresh install.

Both write paths now fsync the temp file *before* the rename, then fsync
the containing directory. Directory fsync is best-effort by design: Windows
cannot open a directory for fsync and some filesystems reject it, so it is
swallowed. The file fsync is the load-bearing part and works everywhere.

Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce.
The async path does not block the main thread.

The syscall-order test mocks `node:fs` and counts fsync targets at the
module boundary, asserting ['file', 'directory'] — proving the ordering
rather than inferring it from reading the implementation, since a fsync
after the rename would still pass every content assertion.

* test(persistence): make the syscall proof platform-aware and actually prove the order

Two problems, both found from CodeRabbit's Windows observation.

The assertion hardcoded ['file', 'directory']. Directory fsync is
deliberately best-effort — Windows cannot open a directory for fsync and
some filesystems reject it — so on Windows the helper swallows the failure,
only the file fsync is observed, and the test fails. The expectation now
probes the real platform instead of assuming, keeping the guarantee tight
where directory fsync works rather than dropping it everywhere.

Worse, the test did not prove what its name claimed. Moving the fsync to
*after* the rename still passes: the file is fsynced either way, and only
fsyncs were recorded, so the correct and broken orders produced an identical
log. Mutation-testing the "before rename" claim is what surfaced this — the
mutation passed.

The rename is now recorded in the same sequence, since it is the boundary
the ordering is defined against. Re-running the same mutation fails, so the
ordering claim is now backed by the test rather than asserted in a comment.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(window): stop burning macOS GPU on an invisible blur effect (#8482) (#10682)

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

* feat(sidebar): distinguish and filter CLI-created workspaces (#10712)

* perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670)

* perf(sidebar): share one worktree-keyed agent orchestration index (#10678)

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

* fix(mobile): recover unreliable relay connections (#10709)

* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.157-rc.0

* docs(relay): trim the pending-output drain comment (#10714)

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

* fix(history): quarantine unreadable recovery generations. (#10713)

* fix(history): quarantine unreadable recovery files

* fix(history): preserve mixed recovery generations

* fix(history): reanchor reconciled live sessions

* fix(history): serialize final checkpoint queue

* fix(history): drain sleep shutdowns before disconnect

* fix(history): restore legacy wide sessions

* fix(history): preserve malformed mixed logs

* fix(history): preserve malformed log tails

* refactor tests to reduce file size

* refactor(history-recovery): extract freeze helper and improve test robus

- Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites
- Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes)
- Replace fixed sleep with deterministic wait for queued exclusive checkpoints
- Distinguish ENOENT (missing) from corrupt in history metadata reads
- Add ceiling-dimension restore test and torn-tail exclusion assertion
- Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures
- Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws

* test(history-recovery): consolidate checkpoint assertions

Wait for both the checkpoint call and set clear atomically to avoid
a timing race where the spy fires before the set is cleared.

* fix(persistence): unbreak main by expecting the new 'cli' card property in fresh defaults (#10722)

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

* fix(sidebar): stop worktree drag from spazzing when cards resize mid-drag (#10725)

* fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693)

* fix(native-chat): wrap question text and option descriptions instead of truncating (#10025)

* fix(mobile): pop to home when leaving a host so the back chevron animates backward (#9723)

* fix(i18n/zh): correct technical literals and clear sense errors (#10048)

* feat(speech): add Korean streaming zipformer STT model (#9893)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(source-control): add copy relative path (#9018)

* fix(gitlab): stop refresh button overlapping dialog close X (#9445)

* fix(gitlab): stop refresh button overlapping dialog close X

The GitLab item dialog's SheetContent renders its own close (X) at
absolute right-4, but the header refresh button sat at the header's
px-5 right padding and overlapped it. Reserve pr-10 on the header so
the refresh button clears the close X, and lift it -mt-1.5 so its icon
aligns with the close X on the same line.

* fix(gitlab): integrate sheet controls into header

---------

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* fix(sidebar): raise selected workspace contrast in dark mode (#8321)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z

Long single-line and structured files wrap by default and misalign.
Surface Word Wrap on the editor more-actions menu for normal file tabs
(diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can
unwrap without opening Settings.

Closes #9974

* fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z

CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving
diff panes out of sync with the markdown actions menu.

* test(editor): verify word wrap shortcut routing

Cover editor/diff setting callbacks and the cross-platform Alt+Z binding.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(repo-icon): detect Tauri and WebP icons (#7942)

Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.

* Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323)

* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets

* Fold the per-sheet Close action into the bulk-close module (session route max-lines)

* fix(mobile): preserve pinned tabs during bulk close

---------

Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* feat: implement Cmd+Enter as commit shortcut in Source Control (#9773)

* feat: implement Cmd+Enter as commit shortcut in Source Control

* test: add unit tests for commit shortcut and tooltip formatting

* fix: address review feedback on modifier keys and test coverage

* test: split mac and windows/linux shortcut and keydown tests

---------

Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(settings): show a way back to local accounts when a remote server owns provider-account scope (#8188)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA voice model (#8207)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog

* test(speech): cover stt-worker-model-config file resolution incl. single-file models

* feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(terminal): stop switch bold flash and Windows lag (#10692)

* fix(terminal): stop bold flash on worktree switch

Worktree hide disposes WebGL and falls back to xterm's DOM renderer.
On reveal, resume ran after paint and flushed backlog against DOM first,
so the first frame used heavier CSS-AA glyphs before WebGL settled.

Resume in useLayoutEffect and reattach WebGL before backlog flush so the
first painted frame stays on the GPU path. No cold-park policy change.

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

* fix(terminal): fit WebGL grid before backlog flush on resume

Adversarial review: resume-before-flush alone wrote TUI backlog onto the
transient DOM↔WebGL one-column-off metrics window. Order is now
resume → fitAllRevealedPanes → flush on heavy reveal and window wake.

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

* fix(terminal): latch viewport intent before WebGL wake resume

Adversarial review: wake path synced intents after resume/fit, which can
re-latch a pinned viewport as followOutput. Capture before reattach and
drop the post-resume re-sync on heavy reveal (outer path already latched).

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

* fix(terminal): complete visibility bookkeeping before PaneManager exists

useLayoutEffect runs before the passive lifecycle creates PaneManager, so
the mount-visible path never set hasCompletedVisibleResume. The first
intra-worktree hide then wrongly suspended WebGL. Bookkeep completion
even when managerRef is still null (extracted helper for max-lines).

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

* fix(terminal): re-sync pin geometry after resume backlog flush

Keep the pre-resume intent latch (reattach must not re-latch pins as
followOutput), then re-sync after flush with preservePinnedAtBottom so
scrollback trim updates absolute pin lines before enforce.

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

* fix(terminal): drop same-tick post-flush intent re-sync

flushTerminalOutput only queues terminal.write and returns before parse, so
a same-tick re-sync read pre-parse resume/fit geometry and could overwrite
pre-resume pins. Keep pre-resume latch + enforce only.

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

* fix(test): expect default worktree card properties to include cli

#10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh
default-profile assertion still omitted it and fails verify.

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

* perf(terminal): retain Windows WebGL across worktree hides

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

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

* fix(mobile): clear native-chat composer optimistically at send time (#10226)

* fix(mobile): clear native-chat composer optimistically at send time

Over relay the send RPC round trip is visible and a lost ack (or a
relay/direct cutover) could strand the sent prompt in the composer
forever: the unconfirmed-send deadline dropped its tracking entry, so a
late transcript echo could never clear the draft.

Clear the draft at send time and restore it only on a definite
rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed
notice; it no longer touches drafts.

* fix(mobile): isolate question answers from composer drafts

* fix(cli): bound orchestration ask timeouts (#10689)

* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail (#10729)

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail

Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry
breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts
consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 ->
unmeasurable, so one post-reload reattach wave exhausts the retry budget once
per mounted pane inside ~60ms.

The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager
and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot
distinguish one pane looping from 34 panes firing once. Coalesce the crumb by
name and carry the live-pane census on the payload instead, so the count
survives without costing 34 ring slots.

Same treatment for WebGL diagnostics, which were worse off: context-loss and
atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a
renderer that dies takes them with it. That bundle had three GPU-process deaths
in the 65s before the renderer OOM and zero WebGL evidence - absence of
instrumentation, not absence of the event. Mirror them into the crash report,
coalesced per kind so a routine atlas reset cannot mask a context loss.

Evidence-only: no behavior, rendering, or lifecycle path changes.

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

* perf(pane-manager): count panes without materializing public views

The census runs on the crash path; getPanes() allocates a full ManagedPane
projection per pane just to read .length.

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

* test(crash-reporting): pin the fit-retry burst against the 30-entry ring

Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a
34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero
pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and
the pane count rides on the payload instead of on the crumb multiplicity.

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

* fix(crash-reporting): name the WebGL census the same as the fit-retry census

The context-loss crumb spread getLivePaneCensus() raw, so one ring described
one measurement two ways: managers/panes here, livePanes/livePaneManagers on
the fit crumb. Spreading also meant renaming the census return keys would
silently reshape the crumb. Name the fields at the call site and pin them.

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

* fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction

The suppression path returned before the delete-then-set that re-anchors
recency, so a key hit continuously never moved from its original insertion
slot and became the first eviction candidate — the inverse of the LRU's
stated intent.

`renderer_error` keys carry message+stack identity, so one noisy render loop
mints unbounded distinct keys. Within a single 30s window that churn evicted
the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and
re-arming the exact ring flush the coalescing exists to prevent.

Re-anchor position only; `recordedAt` is left alone so the suppression window
still expires on schedule rather than renewing on every hit.

Found while adversarially probing the LRU claim in #10729's own description,
which asserted these keys "cannot evict live keys".

* fix(crash-reporting): report the newest census of a coalesced burst

The suppression path wrote nothing to the ring, so a coalesced burst froze
its FIRST event. Panes mount progressively, so pane 1 exhausting alone
legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying
a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1`
with no count: the exact "one pane looping" misread that coalescing by name
was introduced to prevent.

The existing burst test missed this because it fed a constant census on
every crumb, making frozen-first and newest-wins indistinguishable.

Stash the newest payload and fold it into the ring entry the key already
owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33.
Resolution is deferred to snapshot time -- sanitizing per suppressed hit of
a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred.

Two follow-on defects fixed alongside: an expiring key dropped its pending
payload (it loses its only handle on the ring entry), and resolving the
re-emitting key's own old slot double-counted a burst.

---------

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

* fix(speech): download verified model artifacts directly (#10735)

* perf(renderer): give owner-routed settings a stable identity (#10743)

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

* perf(agent-status): validate hook payloads without the JSON round trip (#10752)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored (#10707)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored

Software-rendering fallback only ever considered GPU child crashes in the
first 30s after launch: `if (msSinceLaunch > this.windowMs) return`. Session
12e6ee64 crashed the GPU child 4 times (242s / 920s / 926s / 946s since
launch). The last three span 26.0s — inside windowMs, exactly threshold — but
every one was rejected because the burst began 920s in. The renderer died of
process OOM (0xE0000008) 39s later.

GPU work is demand-driven, so the first heavy compositing often happens
minutes into a session. What distinguishes a broken driver from normal
Chromium churn is that the crashes *cluster*, not when the cluster starts.

Keep a sorted array of recent crash times pruned to windowMs behind the
newest, and engage when the count reaches threshold.

Measured against real field telemetry (341 distinct win32 launches with >=1
GPU crash, from process_gone_suppressed breadcrumb trails): the rolling
window engages on 2/341 launches (0.59%), one of which is 12e6ee64. Max GPU
crashes in any single launch is 4. The closest non-firing sequence
([0, 29531, 55136, 74178] — consecutive gaps that each fit the window but
never put 3 inside it) is pinned as a regression test.

Also destroy the Windows tray before app.exit(0) on this path, matching the
app:relaunch IPC handler — app.exit skips before-quit, and this can now fire
deep into a session rather than only in the first 30s.

Mutation-tested: 6 mutants (launch-anchored gate, dropped pruning, dropped
monotonic clamp, cutoff </<=, threshold >=/>, dropped engaged latch), all
killed by the suite.

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

* fix(gpu-fallback): ask before restarting

---------

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

* feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786)

Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

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

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event (#10058)

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event

Implements STA-2376.

Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds.

Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures.

* fix(daemon): prevent false lifecycle telemetry

* test(daemon): restore once-ness on respawn reason assertions

Keep STA-2376 reason checks without dropping concurrent-respawn
coalescing coverage that prevents double died_respawn telemetry.

* fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn

CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon
without a lifecycle event. Emit trackDaemonReplaced (not retired) so field
diagnosis of #7936 covers the runtime path without mislabeling it as death.

* fix(daemon): stop double-counting resolver replaces; drop redundant version_skew

Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event.

1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects
   but never kills the daemon, so the ensureRunning() that follows re-enters
   createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy +
   0 sessions and emits the replace itself. The closure emitted a second one.
   It also emitted before the outcome was known, so a resolver that recovered
   mid-flight (or a session appearing) left a 'replaced' on the wire for a
   daemon the launcher went on to preserve. The launcher's emit is gated on a
   confirmed kill, so it is the correct sole emitter; this reverts the emit
   added in 1e60ca87a4. The reason plumbing stays -- it is what keeps a
   resolver respawn from being mislabelled died_respawn.

2. version_skew carried no information and lied to one cohort. It was present
   iff reason === 'stale_bundle' and always true, so it was a deterministic
   function of reason. isDaemonStaleForCurrentBundle also returns true when the
   pid file has appVersion: null -- a replace-once heuristic for pre-marker
   builds, where no version comparison happened at all -- so the field asserted
   skew for exactly the upgrade cohort the event exists to illuminate. Dropped
   from the schema, emitter, and call site, along with the dead branching.

3. track() is now failure-isolated in both emitters. Both call sites sit on the
   daemon launch/respawn path, where a throw costs the user every terminal.

Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old
toHaveBeenCalledWith-only assertions passed under a doubled call; a regression
guard that the resolver respawn closure stays silent; and a throwing-client
test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so
no test could observe the double-emit; the once-ness assertions bound each
emitter within its own seam.

Known limitation, unchanged: a wedged-but-alive daemon (#8689) can still report
died_respawn from the adapter and failed_health_check from the launcher -- the
app cannot distinguish wedged from dead at that point.

* fix(daemon): attribute the runtime resolver replace so it is not lost

Round-2 review found the previous commit over-corrected. Removing the emit
from the respawn closures was right about the premature emit but wrong about
where the event would come from instead.

doRespawn() does not kill the daemon, but it does drop its only authenticated
client, and that is enough: the last fully-authenticated disconnect sets
retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with
no grace timer, unlinking the token and PID files. So by the time
ensureRunning() re-enters the launcher, the daemon is already gone --
killStaleDaemon finds no PID file, confirmedReplacement stays false, and the
gate suppresses the emit. Net effect of the previous commit: zero events for a
runtime macOS resolver replacement, the common case.

The double-emit round 1 found was real but narrow: it needs a daemon holding
non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is
false and the daemon survives the disconnect to be killed by the launcher.

Fix: the adapter attributes the reason rather than emitting it, and the launch
it triggers consumes the attribution and reports it. One emit point, exactly
one event, correct reason -- whether the daemon self-retired or survived to be
killed. The attribution is one-shot so a later unrelated launch cannot inherit
it, and it is preferred over the launcher's own inference, which would
otherwise mislabel this as failed_health_check.

Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon
kills the daemon while the outgoing adapter is still live and undisposed, so a
pane respawning on its synthetic exit billed a user action to the crash bucket.

Tests: a regression test that drives the closure and the launcher across the
seam the DaemonSpawner mock normally hides, with killStaleDaemon returning
false to model the self-retired daemon, plus the one-shot assertion. Verified
load-bearing by mutation (neutering the handoff fails it). Also reset
killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous
test's mockResolvedValue in place, which silently disarmed the
confirmedReplacement gate for every test after it.

* fix(daemon): let a proven replacement reason outrank the attributed one

Round-3 review. The attribution was preferred unconditionally, so a launch that
independently proved a different cause reported the wrong one: resolver
unhealthy at the adapter check, daemon survives the client drop (non-alive
sessions keep isIdle() false), resolver recovers milliseconds later, and the
launcher then proves a stale bundle or a different app path and kills on that
basis -- but the event said unhealthy_resolver. A proven kill is grounded in
post-kill truth, so it now wins; the attribution covers only what the gate
cannot see, a daemon that self-retired leaving nothing to kill and no reason to
infer. Every other case is unchanged: self-retired still reports
unhealthy_resolver, and the surviving-daemon case reaches the same reason
through the launcher's own inference.

Also pins the invariant that makes the module-scoped one-shot safe -- the write
reaches the launcher with no await in between -- at both the write and the
consume, since the DaemonSpawner mock lets tests drive the two halves with an
arbitrary gap and would not catch an await being introduced.

Corrects the beforeEach comment from the previous commit: there was never a
plain mockResolvedValue on killStaleDaemonMock in this file, so it did not
silently disarm the gate for later tests. mockReset is still right -- it drops
an unconsumed *Once queue, which mockClear does not.

Tests: a guard that the launcher's proven reason wins over the attribution,
verified load-bearing by mutation (restoring the old ordering fails it).

* fix(daemon): don't let the residual health bucket absorb the resolver reason

Round-4 review caught a regression from the previous commit. Letting any
confirmed kill outrank the attribution was too broad: failed_health_check is
not an identification, it is the residual bucket that also absorbs wedges and
crashes, so preferring it discards the more specific reason the adapter already
established.

This is the likely shape of the incident, not a corner case. The dead macOS
login session that fails the resolver probe also fails the PTY spawn probe, so
checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver
branch is then never evaluated, and with zero live sessions the degraded
preserve does not apply either, so the launch falls through to
failed_health_check and kills a daemon that survived the adapter's disconnect
(non-alive sessions keep it non-idle). Before this commit that reported
failed_health_check and dropped unhealthy_resolver -- burying the flagship
signal in the catch-all.

Rank by how well each reason is evidenced instead: a confirmed kill that
positively identified the daemon wins, the attribution beats the residual
bucket, and the residual bucket is still reported when there is nothing better.
Round 3's motivating case (a proven stale bundle must not be billed to the
resolver) is unaffected and still covered.

Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by
mutation (removing the residual-bucket exclusion fails it).

* test(daemon): cover the confirmation path production actually uses

Round-5 review. No runtime defect, but the three identified reasons were only
ever proven through a mechanism the field never takes.

confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`,
and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle,
and different_app_path all require health === 'healthy', so cleanup reaches the
daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for
killStaleDaemon to find. Production therefore confirms exclusively via
.cleaned. Every test confirmed exclusively via killStaleDaemon, because the
net.connect stub always errors, so cleanup always returned cleaned:false.

The consequence was a silent trapdoor: simplifying the gate to
`confirmedReplacement = await killStaleDaemon(...)` stops all three identified
reasons from ever emitting in the field, and the whole suite stays green.
Confirmed by flipping the killStaleDaemon default to false -- five tests fail,
none of which are meant to be about the kill.

Adds the missing case: cleanup confirms, the kill finds nothing, and
different_app_path is still reported once. Verified load-bearing by mutation --
dropping the .cleaned half now fails this test specifically.

Scoped the new test's mocks to *Once so the identity override cannot leak into
the packaged-bundle test that follows, and hands probeSocketExists back on the
way out since beforeEach only mockClear()s it.

* test(daemon): cover the manual-restart retirement guard

Round-6 review returned land-ready with one note: removing the !restartInFlight
guard from both respawn closures left the whole suite green. That is the same
silent-trapdoor shape the previous commit closed for the .cleaned gate, on a
guard this PR introduced, so it gets the same treatment.

The guard matters because runRestartDaemon tears the daemon down while the
outgoing adapter is still live and undisposed; a pane respawning on its
synthetic exit reaches the death path for what was a user action, and would
bill a manual restart to the crash bucket.

Drives the death from inside the restart's ensureRunning so restartInFlight is
genuinely set by the code under test, rather than asserting against a flag the
test poked itself, and then repeats the respawn after the restart settles to
show the suppression is scoped rather than permanent. Verified load-bearing by
mutation: removing the guard fails this test and nothing else.

* test(daemon): close the two surviving telemetry mutations

Round-7 review returned land-ready with two test-only gaps, both found by
mutation and both the same trapdoor shape as the last two commits.

The manual-restart guard exists in two respawn closures and only the first was
covered. That is the wrong half: the restart installs its own adapter, so from
the second restart onward the copy in runRestartDaemon is the one that actually
runs in the field, and it could be deleted with the suite green. The test now
drives a second restart through the adapter the first one installed.

The privacy-invariant test only built 'replaced' payloads, so .strict() on the
'retired' member was never exercised -- someone adding a field to
trackDaemonRetired after that .strict() was dropped would have reached PostHog
with the test still passing. It now runs the leak set over both transitions,
plus a sanity assertion that each base payload is itself valid so the
rejections prove the leak and not a malformed base.

Both verified load-bearing: neutering the second guard copy, and dropping
.strict() from the retired member, each now fail exactly one test.

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373) (#10065)

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373)

DaemonPtyAdapter.write() sends keystrokes via fire-and-forget client.notify().
When the daemon dies (retirement, crash, kill), the socket disconnects and the
notify is silently dropped — no rejection reaches withDaemonRetry, so the
dead-endpoint respawn never fires and the attached pane freezes. Only a
request/reply RPC (e.g. createOrAttach from opening a new terminal) detected
the death and forked a replacement.

DaemonClient.notify() now reports delivery; a dropped write to a still-active
session drives the shared respawn coalescer directly (reconnecting the
permanent client before releasing the temporary adoption lease, mirroring
withDaemonRetry's ordering), so the pane self-heals like the createOrAttach
path. Cross-platform + SSH-safe: no platform assumptions, pure adapter logic.

Complements (does not duplicate) #8426, which fixes the adjacent in-daemon bug
where a thrown node-pty write no longer marks the handle dead. That is
daemon-side; this is the app-side dropped-notify that never triggered respawn.

* fix(daemon): restore adapter state after dropped-write respawn

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

* fix(daemon): recover sibling panes after daemon death, not just the written one

When a daemon dies, its dropped-write respawn only remounted the pane whose
write detected the dead endpoint. Sibling panes (alive at death but not typed
into) were left frozen: stale prompt pixels, silently-dropped input, no live
child, and no recovery even on later keystrokes — the exact STA-2373
frozen-typing symptom on non-triggering panes.

DaemonPtyAdapter now fans a write-unavailable signal out to every active
session when it recovers from a dead endpoint, emitted while the sessions are
still in activeSessionIds so the renderer's liveness gate still reads them
live. pty.ts forwards each to the existing pty:writeUnavailable channel, so all
panes remount + re-attach through the same path the written pane already used.

Adds a revert-sensitive regression test: with two sessions and only one
written after the daemon dies, the sibling must also be signaled to recover.

* revert(format): drop repo-wide oxfmt churn unrelated to STA-2373

A review pass ran `oxfmt --write .` across the tree, pulling seven files
with no bearing on the dead-daemon respawn fix into the PR diff. Restored
to origin/main byte-for-byte so the diff carries only the respawn change.

* fix(daemon): snapshot active sessions before the write-unavailable fan-out

A listener that kills a pane mutates activeSessionIds mid-iteration, which
can skip the very sibling the fan-out exists to reach. Matches the snapshot
fanoutSyntheticExits already takes.

* fix(daemon): re-arm dead-endpoint recovery on every daemon death

The respawn-storm latch was only released once every awaiting session
rebound. Background sessions have no mounted pane, so nothing ever calls
createOrAttach for them and they hold the awaiting set non-empty forever
— latching the fan-out off after the first death and silently making the
whole fix one-shot. Re-arm on the disconnect event instead, which fires
once per established connection, so the storm guard still holds within a
single incident.

* fix(daemon): route the write-unavailable fan-out through the pty router

Main subscribes on the routed provider, and DaemonPtyRouter is the live
localProvider whenever a legacy daemon socket exists — the common case
when an in-place update bumps PROTOCOL_VERSION with terminals running.
It forwarded write but not onWriteUnavailable, so the fan-out reached no
listener and only the written pane recovered: STA-2373 unfixed, silently.

Also stop rejecting writes on adapters that cannot respawn. Legacy
adapters have no respawn, so the remount reattaches to nothing and
rebuilds the pane empty, losing scrollback the user could still read —
worse than the pre-existing silent drop. And guard the renderer's
write-unavailable handler on ptyId like its sibling data/replay handlers,
so a transport that rebinds without detaching cannot remount a healthy
pane.

* fix(daemon): route the write-unavailable fan-out through the degraded provider

DegradedDaemonPtyProvider is the live localProvider in degraded launch
mode and main subscribes on it, but it forwarded onData/onExit/onReplay/
onBackgroundStreamEvent and not onWriteUnavailable — so the fan-out
reached no listener and siblings stayed frozen. Same defect as the router,
one provider over.

The file sat at its max-lines ceiling, so make room by reusing one
combineUnsubscribes helper across the three places that already repeated
that loop rather than bumping the limit. Forward to the daemon adapters
only: the local fallback has no dead-socket problem.

* refactor(daemon): share the listener-fanout unsubscribe combination

Adding onWriteUnavailable to both provider wrappers left each file at
exactly 300/300 lines, so the next line anyone added would have broken
max-lines with no sanctioned escape hatch. Both already repeated the same
combine-unsubscribes loop, so lift it into one module: duplication drops
and each file gets its headroom back.

* fix(test): stop the fake emitter colliding with the private adapter emitter

DaemonPtyAdapter.emitWriteUnavailable is private, so declaring a public
member of the same name on a mock intersected with DaemonPtyAdapter
collapsed the whole type to never — one collision produced 54 typecheck
errors, taking out pre-existing assertions in both files too. Rename the
fake to triggerWriteUnavailable and declare onWriteUnavailable on
ProviderMock, which IPtyProvider does not carry on this branch.

vitest does not typecheck, which is why a red build sat behind a green
suite.

* fix(quick-commands): show pointer cursor on hover (#10223)

* fix(terminal): limit pre-paint WebGL resume to macOS (#10794)

Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.

* fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)

* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path

* perf(terminal): bound the PR-link carry scan to the trailing window (#10741)

* fix(codex): treat non-Codex transcript as stale resume metadata (#10538)

When a pane is mislabeled agent:codex but still holds a Claude
transcriptPath, the Codex resume guard threw and blocked relaunch.

Only hard-fail when the path claims Codex's dated rollout layout
(sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without
requiring the file to exist. Paths that never claimed Codex provenance
return null so the pane can relaunch.

Keying on rollout shape rather than trusted-home membership matters:
returning null only declines to override CODEX_HOME, and the renderer
has already baked 'codex resume <id>' into the command. A real rollout
under an untrusted home would otherwise resume under whichever account
is selected — and once the session bridge hardlinks rollouts across
homes, codex would find that id and resume silently under the wrong
account. Trust is decided upstream by findTrustedCodexSessionResume.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>

Closes #10517

* fix(codex): cache weekly-only accounts when switching Codex accounts (#10467)

* fix(codex): cache weekly-only accounts when switching Codex accounts

refreshForCodexAccountChange snapshotted the outgoing account only when
this.state.codex.session was populated. Weekly-only plans report no session
window, so their snapshot was dropped and the account switcher's inline bars
rendered empty for exactly those accounts.

Accept a populated weekly window as well. #10136 made this reachable: before
duration-based classification, a weekly-only quota landed in the session slot,
so the gate happened to pass.

Claude is intentionally untouched; it has no weekly-only plan shape.

* test(rate-limits): pin that a windowless outgoing Codex account is not cached

The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher.

* release: v1.4.159-rc.0

* fix(terminal): kill floating, setup, and folder-workspace PTYs on tab close (#10810)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency self-diagnostic (#10784)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic

A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the
same machine, at a scale we cannot reproduce locally (nested worktrees, ~20
agents each). Static analysis across the 155..156 renderer diff found no
perceptible regression, so this adds the instrument to measure it where it
actually happens.

Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI):
reports keydown->paint percentiles from real typing plus a scale census —
agent rows store-total vs mounted-DOM, store listener count, worktree nesting
depth, the settings gating suspect paths, and the focused pane's agent and
buffer mode. Nothing attaches to the keystroke path until start(), so it does
not perturb the latency it measures.

Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey
map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s
idle timeout, so the leading edge of a burst writes immediately and the rest
collapse into one trailing flush. Imperative readers merge the pending stamp,
and a late flush never revives a pane key teardown deleted.

This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing
is a general perf win, not a fix for the reported regression — the measured
saving (~0.02ms/keystroke) is well below perception.

* fix(diagnostic): count React store subscriptions in the listener census

The census wrapped `subscribe` on the bound hook after `create()` had already run.
zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies
subscribe onto the hook as a separate property slot — so patching the hook's copy
counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all
~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows.
The metric would have read a near-constant ~16 regardless of scale, which would
have made "latency tracks listener count" read as false no matter the truth.

The inner api is only reachable as the state creator's third argument, so the
counter now installs there and lives in the store rather than the probe.

Still per-subscribe (component mount), never per-setState: zustand notifies by
iterating its listener Set directly, so this never touches the keystroke path.

Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5.

* fix(ssh): stop remote terminals fail-opening to local PTY (#10778)

* fix(ssh): stop remote terminals fail-opening to local PTY

Docker SSH watcher isolation failed because an unhydrated remote worktree
spawned through the local daemon with a container-only cwd. Fail closed
while the SSH owner is still loading, ignore non-PTY mux notifications
before mapping params.id, and harden the Docker SSH e2e connect helper so
the repo connectionId is present before terminal activation.

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

* fix(terminal): keep host-agnostic terminals off the hydration guard

The unresolved-owner guard also caught floating and inline setup terminals,
which have no repo row by design, regressing #10151. Scope it to repo-backed
worktrees and cover the SSH hydration window with a regression test.

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

* fix(terminal): treat a local-stamped worktree as a resolved host

The hydration guard keyed off "no repo row", which also withheld spawn for a
local worktree whose own hostId already proves its host. Key it off "nothing
names the host" instead, and cover the local-stamped case with a test.

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

* fix(terminal): recover parked panes when their host hydrates

Withholding the spawn stopped the wrong-host PTY but left the pane inert:
nothing bumped its generation once the repo row merged, so a remote terminal
still never opened. Remount PTY-less tabs when repos:changed resolves their
owner, and drop the e2e helper workaround that was masking this.

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

* fix(terminal): scope hydration remounts to panes that actually parked

Keying recovery off "tab has no PTY" also matched tabs whose shell merely
exited, remounting them on every repos:changed. Track the panes that withheld
their spawn and consume each entry once, so recovery cannot churn a live
terminal or spin on repeated refreshes.

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

* test(terminal): cover the repos:changed parked-pane remount wiring

The recovery predicate was unit-tested, but nothing proved useIpcEvents
actually calls it — the existing suite stubs repos.onChanged as a no-op.
Drive the real listener and assert a parked pane is remounted only after
its host resolves.

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

---------

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

* fix(memory): clarify Resource Manager accounting (#10821)

* perf(agent-status): scan Command Code transcripts backward from EOF (#10742)

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

* fix(terminal): quarantine input after a replaced PTY endpoint (STA-2373) (#10811)

Follow-up to #10065, which merged with this failure mode known and deferred.

Recovery from a dead daemon socket reattaches to a fresh shell. Keystrokes in
flight during the ~1.1s window are dropped, but everything typed after reattach
lands on the new shell, so the surviving tail of a half-sent line is submitted by
the user's own Enter: `echo hi; rm -rf x` arrives as `cho hi; rm -rf x` — zsh
fails `cho` and still runs `rm -rf x`. Before #10065 the whole line was lost, so
the executing tail is new.

Quarantine the remainder of the interrupted line instead. Keyed by tab, not pane:
recovery destroys the xterm being typed into and the successor pane receives the
tail. Armed only from the onWriteUnavailable path; a stalled-pipeline remount
keeps the same live shell, where quarantining would eat a real command.

Disarms on the line terminator (CR/LF/Ctrl-C, dropped too since that is the byte
that would submit the mangled line), a 700ms idle gap, or a 5s cap. The cap must
not be shortened: the tail itself takes ~2.5s to type, so a shorter cap fires
mid-tail and delivers the dangerous remainder to the fresh shell.

The onData check sits after the query-reply branch so CPR/DSR replies still reach
the shell.

Live A/B QA on macOS: the bug reproduces verbatim without the fix (marker file
created by the surviving tail) and is suppressed with it, with the next command
still working — proving suppression rather than a dead pane.

* perf(agent-status): keep the shared transcript reader's carry linear (#10777)

readLastTextFromTranscriptOnce re-joined its carry buffer on every block that
held no newline, so a transcript whose tail is one oversized line copied
O(line^2). It backs three readers — the Claude/Codex user prompt, the Command
Code assistant message, and the shared assistant-text reader — so every agent
that resolves turn text from a transcript paid it.

Same chunk-list carry the Command Code prompt reader already uses. Measured on
a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the
gap widens with the line, which is the quadratic signature.

* perf(ai-vault): keep the JSONL line carry linear (#10783)

consumeCompleteJsonlLines re-joined its held-over partial line with every
stream chunk, so one oversized record — a large tool result — cost O(record^2).
It backs the incremental parse for every resumable agent transcript, so the
whole AI Vault corpus paid it.

Hold the pieces in a list and join once, when a newline finally arrives.
Measured on a transcript with a single oversized record: 2.13 ms -> 1.17 ms at
1 MB and 68.22 ms -> 4.35 ms at 8 MB, with byte-identical output. A transcript
of ordinary records never reaches the branch.

* perf(git): read both diff blobs concurrently (#10781)

* perf(git): read both diff blobs concurrently

The diff loaders awaited their two sides in series, so the second `git show`
could not start until the first had returned. The reads are independent, so
that was pure added latency on every diff the review panel opens: ~47 ms
sequential vs ~24 ms concurrent, a saving of ~23 ms per diff.

Covers the merge-base, commit, and staged loaders, plus the unstaged path where
the working-tree read is independent of the index->HEAD chain. The unstaged
left chain itself stays sequential because its second step depends on the first.

The staged coalescing test asserted the sequential shape (one spawn, then the
next); it now pins the contract that actually matters — eight identical reads
still collapse to two spawns, one per side.

* test(perf): interleave the diff-blob benchmark arms

Running one strategy's whole batch before the other's lets cache warming, CPU
frequency drift, and background load correlate with the strategy being measured.
Alternate the arms per iteration, alternate which goes first, and report medians
so that drift stays common to both.

Also reject malformed env settings rather than truncating them — Number.parseInt
accepts "10foo" and 3.5.

Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff.

* perf(mobile-sync): memoize the agent-status projection per entry (#10787)

* perf(mobile-sync): memoize the agent-status projection per entry

buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every
status ping. setAgentStatus replaces one entry and re-spreads the map, which
defeats the reference-equality skip gate, so each ping paid for every other
agent's prompt, 20-entry history, and 8 KB assistant message to discover they
had not changed.

Memoize each row's JSON by entry identity, the cachedTabsProjection pattern
already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents,
0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized
rows matches whole-array stringify, which the new test pins against a verbatim
copy of the old implementation.

* test(perf): stop inflating the projection benchmark baseline

The pre-fix arm stringified each row and parsed it back before stringifying the
array, a per-row roundtrip the original never paid. That made the baseline
artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x.

Share one row builder between both arms, and check equivalence after a ping as
well as on the cold call — a stale-row bug can only surface once the cache is
actually exercised, which the cold-path check could never catch.

* perf(startup): stop parsing qrcode and @linear/sdk at launch (#10788)

Both are reachable only from features most users never touch, but both were in
the main bundle's eager top-level require block.

@linear/sdk is the sharper case: linear-sdk.ts exists solely to load that ~2.6 MB
CJS bundle lazily, and a single value import in issue-relation-write.ts defeated
it for everyone. That file now imports the type and goes through the loader.

qrcode is only reachable from mobile pairing, and both call sites were already
async, so they take a dynamic import.

* fix(codex): rank the Codex resume rescan by selected account, not settings order (#10801)

The legacy Codex session-id rescan (used when a persisted record has no
transcriptPath) returned the FIRST trusted home holding a rollout with that id.
That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the
list ended in per-account homes ordered by settings INSERTION order, so the
account was decided by whichever one the user happened to add first.

Ranks instead: selected account -> real system home -> shared runtime mirror ->
everything else by normalized path. Both ranking inputs are required (an
optional one would silently degrade to pure path order), and the selection
arrives as a thunk so the common provenance-present resume never stats the
ownership marker for a ranking it never runs.

The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard
only fires when the resolved home IS the mirror, and that guard is what
migrates the rollout into ~/.codex. Without it a system-default selection
silently resumed under an arbitrary account and stayed pinned there permanently
once the hook stamped a transcript path.

Reviewed over two independent rounds; every tier individually mutation-proved.
Live-validated in a real Orca dev build by reading the spawned PTY's actual
CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier).

Note: fixes none of #10757's user-visible symptoms on its own — it is a
correctness precondition. Verified on macOS only; Windows coverage is
fixture-only.

* fix(sidebar): make worktree drag reorder follow the card, not the pointer (#10845)

* fix(mobile): harden native chat send budgets, streams, and stop (#10814)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* perf(source-control): share one path collator across the projection (#10850)

* lint: guard the two perf bug shapes we fixed repeatedly (#10851)

* fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841)

* fix(terminal): avoid flash while restoring parked terminals (#10871)

* Fix diff notes overlapping following lines (#7803)

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(terminal): clear search highlights when the find bar closes (#10872)

Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

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

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

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.

* perf(cli): load only the handler group a command dispatches into (#10883)

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

* perf(ssh): normalize watch event paths once per fs.changed batch (#10881)

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

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

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

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

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

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

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

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

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

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

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

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

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

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

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

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

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

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

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

---------

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

* fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882)

* fix(remote): accelerate terminal recovery on resume/online (#8255)

* fix(remote): accelerate shared-control and pane recovery on resume/online

Narrow #8255 onto current main after #9774: fire pending shared-control
reconnect timers and pane recovery backoffs on system resume and browser
online, without replacing the per-pane recovery state machine or reconnect
banner UX.

* test(remote): cover online and occluded-resume recovery triggers

* fix(remote): centralize recovery acceleration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(mobile): unblock iOS releases and prepare 0.0.36 (#10888)

* fix(mobile): block iOS uploads below the last shipped App Store version

The closed-train guard looked up each candidate version's own App Store
record, but a version only gets one once it is submitted for review.
0.0.34 reached TestFlight and was never submitted, so it had no record,
nothing looked closed, and the patch-bump walk stopped there — while
0.0.35 had already shipped. Apple rejected the upload after a 24-minute
build (90186 closed train, 90062 needs a higher CFBundleShortVersionString).

Fetch the highest closed version once and treat everything at or below it
as closed, comparing semver numerically so 0.0.10 outranks 0.0.9.

Also read appVersionState alongside appStoreState: the latter is
deprecated in App Store Connect API 3.3 and renames the shipped state to
READY_FOR_DISTRIBUTION, so reading only the old field would silently find
zero closed versions once Apple stops populating it.

* chore(mobile): prepare 0.0.36

app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because
release versions are resolved on the runner and never committed back.
Close the four-version drift so the checked-in version matches reality
and the iOS release no longer depends on the closed-train walk to find
an open version.

Bump Android versionCode 8 -> 9 in the same commit: the version is shared
between platforms, and shipping 0.0.36 with the code that already shipped
for 0.0.32 produces an APK that cannot install over the released build.

* fix(relay): back off overloaded assignments (#10894)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.160-rc.0

* perf(agent-status): strip terminal control bytes by run, not per character (#10866)

* perf(agent-status): strip terminal control bytes by run, not per character

stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.

Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).

* docs(agent-status): condense the run-copy rationale comments

Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.

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

* test(agent-status): correct terminal strip benchmark

* test(agent-status): bound terminal strip benchmark

---------

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

* perf(usage): yield with setImmediate, not a clamped setTimeout(0) (#10892)

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

* fix(terminal): restore link hover after mouseleave (#10903)

* fix(terminal): restore link hover after mouseleave

* test(terminal): verify mouseleave listener cleanup

* test(terminal): assert link hover listener wiring

* Update README downloads badge

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893)

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459)

Resource Manager decided a session was an "orphan" from the absence of a
renderer binding, then force-killed it with no prompt. Absence of a binding is
not evidence a session is idle — during restore the binding map is legitimately
empty, and deferred SSH sessions never appear in it at all. Live agent sessions
were destroyed this way, losing unrecoverable work.

Three gaps, one rule: only positive evidence authorizes destruction.

- `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the
  renderer could not see the one fact that proves work is running. It now
  reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the
  main handler, both preload surfaces, and the renderer cannot drift.
- The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore
  knows are live on an SSH host but has not reattached. No other binding source
  can see them.
- The bulk-kill handler filtered sessions separately from the button's count,
  so the set killed could differ from the set advertised. Both now call
  `selectUnboundDaemonSessions`.

The single-row kill path had the same defect: it skipped confirmation whenever
`bound` was false. `requiresKillConfirmation` now also holds for agent-owned
sessions, and snapshot-derived rows carry ownership across from the daemon list
rather than reporting `false`.

* fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822)

* feat(usage): price Claude 5 family and GPT-5.6 token usage

Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were
absent from the usage pricing tables, so their turns aggregated tokens
but reported no estimated cost.

Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no
long-context tier: Claude 4.6 and later bill the full 1M window flat.

Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate
that runs through 2026-08-31 — the table has no date dimension.

* fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately

OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit
`-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still
reported no cost. Match it exactly rather than by prefix so it cannot
swallow the tier IDs or a future cheaper variant.

Also split the Claude 5 shadowing guard into per-model breakdown
assertions and add the missing Opus 4.5 fixture the test name claimed.

* docs(usage): note Sonnet 5 uses standard, not introductory, rates

* fix(editor): save floating workspace markdown files (#10985)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634) (#10986)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634)

A project registered on both a local host and an SSH host permanently poisoned
every one of its workspaces with "Workspace identity is ambiguous across hosts.
Refresh projects and try again." Refresh could never help: nothing was stale,
both host setups were valid and intentional. The error survived restarts.

The ambiguity was manufactured. `resolveExactWorktreeRoute` starts from a
worktree that already carries exactly one `hostId`, then throws that away and
asks `resolveIndexedRepoOperationRoute` which host owns the *repo* — a question
with two right answers once a project spans hosts. Only the project spans hosts;
each worktree never does.

Route resolution now filters repo setups to the ones matching the worktree's own
host before looking for a transport, so a two-host project resolves as cleanly as
a one-host project. Genuine ambiguity still returns `ambiguous`.

Second half: the error escaped as an *uncaught renderer error* because passive
background paths — unread marking, activity bumps — called a helper that threw.
Those callers now degrade: `trySettingsForWorktreeOwner` returns null, the
passive update is skipped with a warning, and local state stays consistent.
Explicit user actions still surface the error.

* fix(worktrees): cover every passive path and warn once for ambiguous owners

Adversarial review found the routing fix sound but its coverage thin: only
markWorktreeUnread had an ambiguous-owner test, so restoring the throw in
clearWorktreeUnread or bumpWorktreeActivity would have reproduced the uncaught
renderer error with the suite still green. Both are now covered, verified by
mutation.

bumpWorktreeActivity also skipped silently where the other paths warned. It now
warns — but once per workspace, not per event: activity bumps fire on every PTY
event, so an unbounded warn would flood the console for exactly the users already
hitting this bug.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* perf(runtime): remove timer clamps from cooperative yields (#10908)

* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.

* Implement robust orchestration primitives and connected-server workers (#9925)

* fix(gitlab): render item descriptions and comments with document markdown variant (#9161)

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: parallelize PR checks and accelerate Vite builds (#10989)

* ci: parallelize and accelerate PR checks

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

* fix(ci): address review findings

* fix(ci): retry transient Electron downloads

* test(ci): cover Electron download retry limits

* fix(terminals): negotiate explicit close intent for paired runtimes (#10129)

* fix(cmd-j): focus the destination workspace's own terminal after a jump (#10695)

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

* fix(jira): render issue description/comment images with lightbox (#8938)

* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>

* fix(hooks): drain POSIX hook stdin without PATH (#10885)

Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat.

* fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)

A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When
its parent shell survives, the emulator keeps mouse tracking, focus reporting
and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR
motion reports at the prompt, and the doomed process burns CPU parsing the
motion firehose while it lives.

Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill,
daemon reattach), so an agent dying under a live shell crossed none of them.

Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed
return-to-shell transition, next to the sibling stale-title cleanup. That
transition is gated on a real foreground-process read rather than the bare
OSC 133;D, because a full-screen agent's nested command shells leak their own D
onto the main PTY. The write goes through the replay guard so xterm's auto
replies cannot leak to the shell as input.

The reused constant already excludes ?2004l, so the bracketed-paste protection
the live shell re-arms at its prompt is preserved.

* fix(release): restore the Windows inner-binary signature gate (#6487) (#10719)

* fix(release): restore the Windows inner-binary signature gate

electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's
hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The
gate is fail-open, so it swallowed the error and 11 releases shipped with
no signature verification and an evidence artifact that looked clean.

Resolve 7za through app-builder-lib's toolset instead, and always record a
verdict so a degraded gate can't pass for a healthy one.

Refs #6487

* test(release): make the signing-gate structural tests assert executed code, not text

The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.

Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.

Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.

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

* test(release): pin the evidence filename the gate writes to the one the upload collects

The upload step is `if-no-files-found: ignore`, so renaming the evidence file on
one side and not the other ships a green run whose artifact silently omits the
verdict — the same silent-degradation class this PR exists to close.

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

* fix(release): preserve 7za resolver failures

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748)

Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch.

* fix(pty): strip inherited Claude child-session stamps at spawn (#9961)

An Orca GUI or daemon launched from inside a Claude Code session inherits
CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID.
Every spawn path spreads the host's process.env, so each terminal Orca opens is
marked a nested Claude child and Claude silently disables transcript
persistence — real sessions stop writing on-disk history with no visible error.

Older-protocol daemons are deliberately preserved across upgrades and the
auto-updater relaunch inherits the previous app's env, so one contaminated
launch propagates through subsequent updates.

Orca never sets these variables, so an inherited value is always poison. Add a
deny constant plus an inherited-only filter merged into envToDelete at both pty
spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the
agent-hook keys this is not gated on isDaemonHostSpawn, because the local
provider and the relay host spread their own process.env too.

Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a
`string[] | undefined` intermediate into a `readonly string[]` parameter and did
not typecheck); coverage extends to the runtime-controller spawn path, the local
provider, and the SSH route, whose exact spawn-options assertion had to be
updated because envToDelete is no longer ever undefined.

* fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684)

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

* fix(cmd-j): restore focus when issue match routing declines (#11010)

* fix(markdown): render task continuations as text (#11008)

* fix(dashboard): stop the agent icon shrinking on long card titles (#11001)

A bare <svg> flex item shrinks with its row, so kanban cards and the
terminal dialog rendered a squashed ~9px agent icon whenever the worktree
name overflowed. Wrap both in the shrink-0 span every other surface
already uses.

* fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)

* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.

* fix(browser): localize degraded cookie import warnings

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: grboy6770 <2000grboy@gmail.com>
Co-authored-by: ye4241 <6803102+ye4241@users.noreply.github.com>
Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Co-authored-by: SoonKim <sztolabs@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Ken Fukuyama <kenfdev@gmail.com>
Co-authored-by: SilvaViniF <108766450+SilvaViniF@users.noreply.github.com>
Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Anishek Kamal <anishekkamal@gmail.com>
Co-authored-by: BingZ <zzb@gxsmjx.com>
Co-authored-by: Tom <debres.bot@gmail.com>
Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@student.ucll.be>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: xsacdw <251566507+xsacdw@users.noreply.github.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: reopard007 <reonce@gmail.com>
Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Nemo <imshuazi@126.com>
Co-authored-by: nolainjin <nolainjin@gmail.com>
Co-authored-by: David Anderson <215816+akapug@users.noreply.github.com>
Co-authored-by: Vansh <77244334+vansh2408@users.noreply.github.com>
2026-07-27 16:40:29 -07:00
Neil 10ca89ac8b feat(updater): switch to validated local mac builds (#10889)
* feat(updater): switch to validated local mac builds

* test(updater): cover local build recovery actions

* fix(types): keep local build contract in project sources
2026-07-27 16:36:39 -07:00
cdd5ceb72b fix(jira): render issue description/comment images with lightbox (#8938)
* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-07-27 15:34:20 -07:00
OrcaWin 24706ccff0 fix(terminals): negotiate explicit close intent for paired runtimes (#10129) 2026-07-27 15:22:55 -07:00
OrcaWin cd05f2ff93 Implement robust orchestration primitives and connected-server workers (#9925) 2026-07-27 12:31:37 -07:00
Neil 8b154d686c perf(runtime): remove timer clamps from cooperative yields (#10908)
* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.
2026-07-27 12:21:14 -07:00
Jinwoo HongandOrcaWin 05603a2e78 fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893)
* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459)

Resource Manager decided a session was an "orphan" from the absence of a
renderer binding, then force-killed it with no prompt. Absence of a binding is
not evidence a session is idle — during restore the binding map is legitimately
empty, and deferred SSH sessions never appear in it at all. Live agent sessions
were destroyed this way, losing unrecoverable work.

Three gaps, one rule: only positive evidence authorizes destruction.

- `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the
  renderer could not see the one fact that proves work is running. It now
  reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the
  main handler, both preload surfaces, and the renderer cannot drift.
- The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore
  knows are live on an SSH host but has not reattached. No other binding source
  can see them.
- The bulk-kill handler filtered sessions separately from the button's count,
  so the set killed could differ from the set advertised. Both now call
  `selectUnboundDaemonSessions`.

The single-row kill path had the same defect: it skipped confirmation whenever
`bound` was false. `requiresKillConfirmation` now also holds for agent-owned
sessions, and snapshot-derived rows carry ownership across from the daemon list
rather than reporting `false`.

* fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 11:52:55 -07:00
NeilandOrca 165e4e0d1b perf(agent-status): strip terminal control bytes by run, not per character (#10866)
* perf(agent-status): strip terminal control bytes by run, not per character

stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.

Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).

* docs(agent-status): condense the run-copy rationale comments

Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.

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

* test(agent-status): correct terminal strip benchmark

* test(agent-status): bound terminal strip benchmark

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 02:20:56 -07:00
BingZandOrcaWin b96c2f0582 fix(remote): accelerate terminal recovery on resume/online (#8255)
* fix(remote): accelerate shared-control and pane recovery on resume/online

Narrow #8255 onto current main after #9774: fire pending shared-control
reconnect timers and pane recovery backoffs on system resume and browser
online, without replacing the per-pane recovery state machine or reconnect
banner UX.

* test(remote): cover online and occluded-resume recovery triggers

* fix(remote): centralize recovery acceleration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 01:43:44 -07:00
NeilandOrca 97e4776dfe feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)
* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

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

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

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

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

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

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

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

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

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

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

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

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

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

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

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

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 01:14:33 -07:00
NeilandOrca 7dab1e86e2 perf(ssh): normalize watch event paths once per fs.changed batch (#10881)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 01:02:48 -07:00
Neil a1a78da878 fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841) 2026-07-26 22:39:32 -07:00
Neil a8a2e6cb1f perf(agent-status): keep the shared transcript reader's carry linear (#10777)
readLastTextFromTranscriptOnce re-joined its carry buffer on every block that
held no newline, so a transcript whose tail is one oversized line copied
O(line^2). It backs three readers — the Claude/Codex user prompt, the Command
Code assistant message, and the shared assistant-text reader — so every agent
that resolves turn text from a transcript paid it.

Same chunk-list carry the Command Code prompt reader already uses. Measured on
a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the
gap widens with the line, which is the quadratic signature.
2026-07-26 20:31:46 -07:00
NeilandOrca 4109f4eec5 perf(agent-status): scan Command Code transcripts backward from EOF (#10742)
Co-authored-by: Orca <help@stably.ai>
2026-07-26 20:20:12 -07:00
Neil 6b16c20796 fix(memory): clarify Resource Manager accounting (#10821) 2026-07-26 19:46:29 -07:00
Neil c8e4488479 perf(terminal): bound the PR-link carry scan to the trailing window (#10741) 2026-07-26 15:32:33 -07:00
Neil 4681edb520 fix(terminal): limit pre-paint WebGL resume to macOS (#10794)
Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.
2026-07-26 15:24:26 -07:00
Brennan Benson fca69a904a feat(daemon): add daemon_lifecycle replaced/retired telemetry event (#10058)
* feat(daemon): add daemon_lifecycle replaced/retired telemetry event

Implements STA-2376.

Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds.

Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures.

* fix(daemon): prevent false lifecycle telemetry

* test(daemon): restore once-ness on respawn reason assertions

Keep STA-2376 reason checks without dropping concurrent-respawn
coalescing coverage that prevents double died_respawn telemetry.

* fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn

CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon
without a lifecycle event. Emit trackDaemonReplaced (not retired) so field
diagnosis of #7936 covers the runtime path without mislabeling it as death.

* fix(daemon): stop double-counting resolver replaces; drop redundant version_skew

Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event.

1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects
   but never kills the daemon, so the ensureRunning() that follows re-enters
   createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy +
   0 sessions and emits the replace itself. The closure emitted a second one.
   It also emitted before the outcome was known, so a resolver that recovered
   mid-flight (or a session appearing) left a 'replaced' on the wire for a
   daemon the launcher went on to preserve. The launcher's emit is gated on a
   confirmed kill, so it is the correct sole emitter; this reverts the emit
   added in 1e60ca87a4. The reason plumbing stays -- it is what keeps a
   resolver respawn from being mislabelled died_respawn.

2. version_skew carried no information and lied to one cohort. It was present
   iff reason === 'stale_bundle' and always true, so it was a deterministic
   function of reason. isDaemonStaleForCurrentBundle also returns true when the
   pid file has appVersion: null -- a replace-once heuristic for pre-marker
   builds, where no version comparison happened at all -- so the field asserted
   skew for exactly the upgrade cohort the event exists to illuminate. Dropped
   from the schema, emitter, and call site, along with the dead branching.

3. track() is now failure-isolated in both emitters. Both call sites sit on the
   daemon launch/respawn path, where a throw costs the user every terminal.

Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old
toHaveBeenCalledWith-only assertions passed under a doubled call; a regression
guard that the resolver respawn closure stays silent; and a throwing-client
test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so
no test could observe the double-emit; the once-ness assertions bound each
emitter within its own seam.

Known limitation, unchanged: a wedged-but-alive daemon (#8689) can still report
died_respawn from the adapter and failed_health_check from the launcher -- the
app cannot distinguish wedged from dead at that point.

* fix(daemon): attribute the runtime resolver replace so it is not lost

Round-2 review found the previous commit over-corrected. Removing the emit
from the respawn closures was right about the premature emit but wrong about
where the event would come from instead.

doRespawn() does not kill the daemon, but it does drop its only authenticated
client, and that is enough: the last fully-authenticated disconnect sets
retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with
no grace timer, unlinking the token and PID files. So by the time
ensureRunning() re-enters the launcher, the daemon is already gone --
killStaleDaemon finds no PID file, confirmedReplacement stays false, and the
gate suppresses the emit. Net effect of the previous commit: zero events for a
runtime macOS resolver replacement, the common case.

The double-emit round 1 found was real but narrow: it needs a daemon holding
non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is
false and the daemon survives the disconnect to be killed by the launcher.

Fix: the adapter attributes the reason rather than emitting it, and the launch
it triggers consumes the attribution and reports it. One emit point, exactly
one event, correct reason -- whether the daemon self-retired or survived to be
killed. The attribution is one-shot so a later unrelated launch cannot inherit
it, and it is preferred over the launcher's own inference, which would
otherwise mislabel this as failed_health_check.

Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon
kills the daemon while the outgoing adapter is still live and undisposed, so a
pane respawning on its synthetic exit billed a user action to the crash bucket.

Tests: a regression test that drives the closure and the launcher across the
seam the DaemonSpawner mock normally hides, with killStaleDaemon returning
false to model the self-retired daemon, plus the one-shot assertion. Verified
load-bearing by mutation (neutering the handoff fails it). Also reset
killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous
test's mockResolvedValue in place, which silently disarmed the
confirmedReplacement gate for every test after it.

* fix(daemon): let a proven replacement reason outrank the attributed one

Round-3 review. The attribution was preferred unconditionally, so a launch that
independently proved a different cause reported the wrong one: resolver
unhealthy at the adapter check, daemon survives the client drop (non-alive
sessions keep isIdle() false), resolver recovers milliseconds later, and the
launcher then proves a stale bundle or a different app path and kills on that
basis -- but the event said unhealthy_resolver. A proven kill is grounded in
post-kill truth, so it now wins; the attribution covers only what the gate
cannot see, a daemon that self-retired leaving nothing to kill and no reason to
infer. Every other case is unchanged: self-retired still reports
unhealthy_resolver, and the surviving-daemon case reaches the same reason
through the launcher's own inference.

Also pins the invariant that makes the module-scoped one-shot safe -- the write
reaches the launcher with no await in between -- at both the write and the
consume, since the DaemonSpawner mock lets tests drive the two halves with an
arbitrary gap and would not catch an await being introduced.

Corrects the beforeEach comment from the previous commit: there was never a
plain mockResolvedValue on killStaleDaemonMock in this file, so it did not
silently disarm the gate for later tests. mockReset is still right -- it drops
an unconsumed *Once queue, which mockClear does not.

Tests: a guard that the launcher's proven reason wins over the attribution,
verified load-bearing by mutation (restoring the old ordering fails it).

* fix(daemon): don't let the residual health bucket absorb the resolver reason

Round-4 review caught a regression from the previous commit. Letting any
confirmed kill outrank the attribution was too broad: failed_health_check is
not an identification, it is the residual bucket that also absorbs wedges and
crashes, so preferring it discards the more specific reason the adapter already
established.

This is the likely shape of the incident, not a corner case. The dead macOS
login session that fails the resolver probe also fails the PTY spawn probe, so
checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver
branch is then never evaluated, and with zero live sessions the degraded
preserve does not apply either, so the launch falls through to
failed_health_check and kills a daemon that survived the adapter's disconnect
(non-alive sessions keep it non-idle). Before this commit that reported
failed_health_check and dropped unhealthy_resolver -- burying the flagship
signal in the catch-all.

Rank by how well each reason is evidenced instead: a confirmed kill that
positively identified the daemon wins, the attribution beats the residual
bucket, and the residual bucket is still reported when there is nothing better.
Round 3's motivating case (a proven stale bundle must not be billed to the
resolver) is unaffected and still covered.

Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by
mutation (removing the residual-bucket exclusion fails it).

* test(daemon): cover the confirmation path production actually uses

Round-5 review. No runtime defect, but the three identified reasons were only
ever proven through a mechanism the field never takes.

confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`,
and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle,
and different_app_path all require health === 'healthy', so cleanup reaches the
daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for
killStaleDaemon to find. Production therefore confirms exclusively via
.cleaned. Every test confirmed exclusively via killStaleDaemon, because the
net.connect stub always errors, so cleanup always returned cleaned:false.

The consequence was a silent trapdoor: simplifying the gate to
`confirmedReplacement = await killStaleDaemon(...)` stops all three identified
reasons from ever emitting in the field, and the whole suite stays green.
Confirmed by flipping the killStaleDaemon default to false -- five tests fail,
none of which are meant to be about the kill.

Adds the missing case: cleanup confirms, the kill finds nothing, and
different_app_path is still reported once. Verified load-bearing by mutation --
dropping the .cleaned half now fails this test specifically.

Scoped the new test's mocks to *Once so the identity override cannot leak into
the packaged-bundle test that follows, and hands probeSocketExists back on the
way out since beforeEach only mockClear()s it.

* test(daemon): cover the manual-restart retirement guard

Round-6 review returned land-ready with one note: removing the !restartInFlight
guard from both respawn closures left the whole suite green. That is the same
silent-trapdoor shape the previous commit closed for the .cleaned gate, on a
guard this PR introduced, so it gets the same treatment.

The guard matters because runRestartDaemon tears the daemon down while the
outgoing adapter is still live and undisposed; a pane respawning on its
synthetic exit reaches the death path for what was a user action, and would
bill a manual restart to the crash bucket.

Drives the death from inside the restart's ensureRunning so restartInFlight is
genuinely set by the code under test, rather than asserting against a flag the
test poked itself, and then repeats the respawn after the restart settles to
show the suppression is scoped rather than permanent. Verified load-bearing by
mutation: removing the guard fails this test and nothing else.

* test(daemon): close the two surviving telemetry mutations

Round-7 review returned land-ready with two test-only gaps, both found by
mutation and both the same trapdoor shape as the last two commits.

The manual-restart guard exists in two respawn closures and only the first was
covered. That is the wrong half: the restart installs its own adapter, so from
the second restart onward the copy in runRestartDaemon is the one that actually
runs in the field, and it could be deleted with the suite green. The test now
drives a second restart through the adapter the first one installed.

The privacy-invariant test only built 'replaced' payloads, so .strict() on the
'retired' member was never exercised -- someone adding a field to
trackDaemonRetired after that .strict() was dropped would have reached PostHog
with the test still passing. It now runs the leak set over both transitions,
plus a sanity assertion that each base payload is itself valid so the
rejections prove the leak and not a malformed base.

Both verified load-bearing: neutering the second guard copy, and dropping
.strict() from the retired member, each now fail exactly one test.
2026-07-26 14:51:21 -07:00
NeilandOrca af708d3471 feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786)
Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

Co-authored-by: Orca <help@stably.ai>
2026-07-26 14:42:05 -07:00
Neil b168f6f100 perf(agent-status): validate hook payloads without the JSON round trip (#10752) 2026-07-26 13:20:23 -07:00
Neil c30a0ea685 fix(speech): download verified model artifacts directly (#10735) 2026-07-26 13:14:57 -07:00
NeilandOrca ab1c37889a fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail (#10729)
* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail

Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry
breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts
consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 ->
unmeasurable, so one post-reload reattach wave exhausts the retry budget once
per mounted pane inside ~60ms.

The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager
and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot
distinguish one pane looping from 34 panes firing once. Coalesce the crumb by
name and carry the live-pane census on the payload instead, so the count
survives without costing 34 ring slots.

Same treatment for WebGL diagnostics, which were worse off: context-loss and
atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a
renderer that dies takes them with it. That bundle had three GPU-process deaths
in the 65s before the renderer OOM and zero WebGL evidence - absence of
instrumentation, not absence of the event. Mirror them into the crash report,
coalesced per kind so a routine atlas reset cannot mask a context loss.

Evidence-only: no behavior, rendering, or lifecycle path changes.

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

* perf(pane-manager): count panes without materializing public views

The census runs on the crash path; getPanes() allocates a full ManagedPane
projection per pane just to read .length.

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

* test(crash-reporting): pin the fit-retry burst against the 30-entry ring

Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a
34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero
pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and
the pane count rides on the payload instead of on the crumb multiplicity.

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

* fix(crash-reporting): name the WebGL census the same as the fit-retry census

The context-loss crumb spread getLivePaneCensus() raw, so one ring described
one measurement two ways: managers/panes here, livePanes/livePaneManagers on
the fit crumb. Spreading also meant renaming the census return keys would
silently reshape the crumb. Name the fields at the call site and pin them.

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

* fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction

The suppression path returned before the delete-then-set that re-anchors
recency, so a key hit continuously never moved from its original insertion
slot and became the first eviction candidate — the inverse of the LRU's
stated intent.

`renderer_error` keys carry message+stack identity, so one noisy render loop
mints unbounded distinct keys. Within a single 30s window that churn evicted
the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and
re-arming the exact ring flush the coalescing exists to prevent.

Re-anchor position only; `recordedAt` is left alone so the suppression window
still expires on schedule rather than renewing on every hit.

Found while adversarially probing the LRU claim in #10729's own description,
which asserted these keys "cannot evict live keys".

* fix(crash-reporting): report the newest census of a coalesced burst

The suppression path wrote nothing to the ring, so a coalesced burst froze
its FIRST event. Panes mount progressively, so pane 1 exhausting alone
legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying
a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1`
with no count: the exact "one pane looping" misread that coalescing by name
was introduced to prevent.

The existing burst test missed this because it fed a constant census on
every crumb, making frozen-first and newest-wins indistinguishable.

Stash the newest payload and fold it into the ring entry the key already
owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33.
Resolution is deferred to snapshot time -- sanitizing per suppressed hit of
a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred.

Two follow-on defects fixed alongside: an expiring key dropped its pending
payload (it loses its only handle on the ring entry), and resolving the
re-emitting key's own old slot double-counted a burst.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-26 12:55:45 -07:00
Jinjing 76b2a3b44d fix(cli): bound orchestration ask timeouts (#10689)
* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries
2026-07-26 12:50:05 -07:00
NeilandOrca 8f5a45401f fix(terminal): stop switch bold flash and Windows lag (#10692)
* fix(terminal): stop bold flash on worktree switch

Worktree hide disposes WebGL and falls back to xterm's DOM renderer.
On reveal, resume ran after paint and flushed backlog against DOM first,
so the first frame used heavier CSS-AA glyphs before WebGL settled.

Resume in useLayoutEffect and reattach WebGL before backlog flush so the
first painted frame stays on the GPU path. No cold-park policy change.

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

* fix(terminal): fit WebGL grid before backlog flush on resume

Adversarial review: resume-before-flush alone wrote TUI backlog onto the
transient DOM↔WebGL one-column-off metrics window. Order is now
resume → fitAllRevealedPanes → flush on heavy reveal and window wake.

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

* fix(terminal): latch viewport intent before WebGL wake resume

Adversarial review: wake path synced intents after resume/fit, which can
re-latch a pinned viewport as followOutput. Capture before reattach and
drop the post-resume re-sync on heavy reveal (outer path already latched).

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

* fix(terminal): complete visibility bookkeeping before PaneManager exists

useLayoutEffect runs before the passive lifecycle creates PaneManager, so
the mount-visible path never set hasCompletedVisibleResume. The first
intra-worktree hide then wrongly suspended WebGL. Bookkeep completion
even when managerRef is still null (extracted helper for max-lines).

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

* fix(terminal): re-sync pin geometry after resume backlog flush

Keep the pre-resume intent latch (reattach must not re-latch pins as
followOutput), then re-sync after flush with preservePinnedAtBottom so
scrollback trim updates absolute pin lines before enforce.

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

* fix(terminal): drop same-tick post-flush intent re-sync

flushTerminalOutput only queues terminal.write and returns before parse, so
a same-tick re-sync read pre-parse resume/fit geometry and could overwrite
pre-resume pins. Keep pre-resume latch + enforce only.

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

* fix(test): expect default worktree card properties to include cli

#10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh
default-profile assertion still omitted it and fails verify.

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

* perf(terminal): retain Windows WebGL across worktree hides

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-26 05:29:38 -07:00
f5f026649e feat(speech): add Parakeet TDT-CTC 0.6B JA voice model (#8207)
* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog

* test(speech): cover stt-worker-model-config file resolution incl. single-file models

* feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-07-26 03:01:41 -07:00
d8af1d2196 Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436)
* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-07-26 02:43:56 -07:00
BingZ 248c0d9cda fix(repo-icon): detect Tauri and WebP icons (#7942)
Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.
2026-07-26 02:32:05 -07:00
Wooseong KimandNeil f3d8edb29e feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086)
* feat(editor): toggle Word Wrap from file tab actions and Alt+Z

Long single-line and structured files wrap by default and misalign.
Surface Word Wrap on the editor more-actions menu for normal file tabs
(diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can
unwrap without opening Settings.

Closes #9974

* fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z

CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving
diff panes out of sync with the markdown actions menu.

* test(editor): verify word wrap shortcut routing

Cover editor/diff setting callbacks and the cross-platform Alt+Z binding.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-07-26 02:23:36 -07:00
Jinwoo HongandOrcaWin 07671d4a06 fix(mobile): recover unreliable relay connections (#10709)
* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-26 00:48:06 -07:00
Neil 28dfc13654 feat(sidebar): distinguish and filter CLI-created workspaces (#10712) 2026-07-26 00:41:03 -07:00
Neil c67aadbc18 fix(crash-reporting): record exact V8 heap sizes, not Blink's quantized ones (#10683) 2026-07-25 22:54:20 -07:00
9042ef9792 fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default (#10588)
* fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default

Zellij and other multiplexers copy via OSC 52. Empty Pc is a valid XTerm
default for clipboard, but we rejected it, and the feature defaulted off so
copy silently failed inside Zellij. Accept empty Pc as clipboard, default the
setting on (query still blocked; size capped), and surface Zellij in settings.

Closes #10567

* fix(review): make the OSC 52 default actually reach existing installs

Review fixes for #10588:

- Persistence: profiles saved under the old off default persisted `false`,
  which is indistinguishable from a real opt-out, so the default flip never
  reached #10567's reporter. Added the repo's one-shot stamp
  (terminalAllowOsc52ClipboardDefaultedOnForAllUsers) so unmigrated profiles
  flip once and a later opt-out sticks.
- Replay: reattach/cold-restore re-writes recorded PTY bytes through the same
  parser, so a stale `\e]52;c;...` silently clobbered the clipboard on every
  restart. Gated behind isPaneReplaying via a new resolveOsc52ClipboardGate.
- Blocked toast latches once per renderer session and could be burned by a
  pre-hydration read; it now fires only for a real opt-out.
- An empty Pd decoded to '' and, with the gate default-on, silently blanked
  the clipboard. Now rejected as invalid.
- Localization: en.json is bundled and the catalog beats the code fallback,
  so all three copy changes were inert. Resynced across five locales.
- Corrected the empty-Pc rationale: tmux (not Zellij) emits `\e]52;;<b64>`.

* test(terminal): cover the OSC 52 gate wiring and settings copy

Extracts createOsc52OscHandler so the replay/hydration gate wiring is
covered, not just the pure gate — dropping the isReplaying getter now
fails a test instead of passing silently.

Adds catalog assertions for the two OSC 52 settings strings. Only the
toast key was pinned, so the same inert-copy regression (code fallback
edited, bundled en.json not) could still ship for the settings pane.

* docs(settings): note that the OSC 52 default only covers new profiles

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

* fix(terminal): migrate the web settings store to the OSC 52 default-on flip

The default-on flip only reached the Electron store. The web/remote client
keeps its own settings in localStorage, so a profile that persisted the old
`false` there stayed opted out — the same bug the Electron migration fixed,
in the second store.

Extract the migration into shared/osc52-clipboard-settings.ts and call it
from both stores. Also coalesce OSC 52 writes onto a microtask so a hostile
chunk of ~15-byte sequences cannot fan out into a million clipboard writes,
and latch the blocked-write toast after it renders rather than before.

* feat(terminal): tell users when the OSC 52 flip overrides their opt-out

The default-on migration cannot distinguish a deliberate opt-out from a
profile that simply never touched the setting — both persisted `false` under
the old default. Flipping everyone is the only way to fix #10567 for existing
installs, but doing it silently reverses a security choice the user made.

Arm a one-shot notice at load when the migration overrides a persisted
`false`, on both settings stores, and show it once the renderer hydrates.
Profiles that never opted out are never notified.

* fix(terminal): clear the OSC 52 notice after it renders, not before

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

* fix(terminal): keep the web OSC 52 notice armed against an unmigrated host

The host store always projects osc52ClipboardDefaultOnNoticePending, so the
plain spread in the web client's runtime UI merge overwrote an arm raised by
its own localStorage settings migration — flipping the opt-out in silence.

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

* fix(terminal): stop the OSC 52 notice overclaiming, and cover it

Round-3 review fixes:
- Rename the arming predicate to osc52ClipboardDefaultOnOverridesPersistedOff.
  Both stores rewrite the whole settings object on every save, so every profile
  saved under the old off default holds `false` — the deliberate-opt-out cohort
  is not distinguishable on disk. Name, docs and test names now say so.
- Read settings before the UI snapshot in readLocalWebUIState: getStoredSettings()
  arms the notice, so reading first snapshotted a pre-arm state that callers wrote
  back, erasing an arm the stamp can never raise again.
- Give the notice toast a stable id; StrictMode re-runs the effect against the
  same closure, so the early return cannot catch the second pass.
- Restore guardParserHandler parity in the coalescer microtask.
- Drop the unverified Zellij claim justifying all-selections routing; that routing
  predates this branch and PRIMARY routing stays an open question.
- Cover the notice hook (order, single-fire, deep-link), the armed flag reaching
  disk and surviving a clear, and pin the notice catalog to its code fallbacks.

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

* test(terminal): pin OSC 52 setting discovery by product name

The migration notice says to turn it off in Terminal settings, so searching
Zellij/Grok/tmux has to find it. Also note why the OSC 52 write-back clauses
stay despite an unrelated always-true clause in the same condition.

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

* fix(terminal): consume the OSC 52 notice on close, and cover the guards it relies on

The notice was cleared the moment the toast was enqueued, so a quit inside its
15s window spent the profile's only warning on a launch where nothing was ever
seen — and the settings stamp means it can never re-arm. Clear on
onAutoClose/onDismiss instead, plus explicitly in the action handler, because
sonner's action path deletes the toast without firing onDismiss.

Also closes three coverage gaps a review found:
- ui.set must accept osc52ClipboardDefaultOnNoticePending. The update schema is
  strict, so dropping the key rejects the whole call rather than stripping it,
  and the renderer only logs that failure — every paired client would re-toast
  forever with nothing red.
- the coalescer's try/catch and .catch had no test; the rejection case needs a
  plain function because vi.fn tracks settled results and hides the leak.
- pin that every selection kind (including bare `p`) lands in the system
  clipboard, so routing PRIMARY separately later is a deliberate break.

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

* test(web): pin that ui.get arms the OSC 52 notice when it runs the migration

readLocalWebUIState reads settings before the UI blob so the migration's arm is
in place before the snapshot every caller writes back. Seeding localStorage
after install is what makes ui.get the first settings read, and therefore what
makes swapping those two lines fail.

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

* test(store): cover the OSC 52 notice clear and its hydration

The clear sets local state before persisting so a rejected ui.set cannot leave
the toast re-firing for the rest of the session; losing the persist only re-arms
the notice next launch.

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

* docs(terminal): state the real residual risk of default-on OSC 52

Three comment corrections from review:
- the safety note claimed exfil was the risk; queries are blocked, so it isn't.
  The actual accepted risk is execute-on-paste: decoded text goes to the
  clipboard verbatim, newlines included. Filtering here would break multi-line
  TUI copies, which is the feature; bracketed paste is where that is handled,
  and kitty/Ghostty take the same posture.
- the coalescer bounds a flood per parse yield, not overall.
- the replay gate reads at parse time while queued live bytes are drained
  before the guard engages, so a copy racing a reattach is dropped silently.

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

* test(terminal): close the four OSC 52 gaps a full revert walked through

Mutation testing found four assertions that stayed green against the very
change they were written to pin.

The notice suite passed 8/9 against a complete revert to clear-at-enqueue:
`calls[0][1][callback]?.()` is a silent no-op when the option is absent, and
the call count was already satisfied by the enqueue-clear, so nothing
separated "cleared by this callback" from "cleared earlier". Assert the
option exists and the notice is unspent before invoking it.

The stable toast id was deletable with all 9 green despite the adjacent
comment calling it load-bearing for StrictMode. Pin it.

The blocked toast's latch-after-throw fix was unproven: both orderings pass
when `toast.info` succeeds. Only a throwing first call tells them apart.

Deleting the hook call in App.tsx silenced the desktop notice with every
suite green. Pin it alongside the static Toaster import, since sonner drops
a toast enqueued before any Toaster subscribes and never replays it.

Also retone the coalescer-latch comment, which claimed the reset ordering
was load-bearing on its own; the try/catch reaches the same end, so the
test binds the pair.

All four verified green->red by mutation, then restored.

* test(terminal): cover the OSC 52 notice and its guards

Add tests pinning the static Toaster mount required to prevent notice dropout (#10567), the stable toast ID deduping StrictMode double-invokes, that the notice stays unspent on toast throws, and that flush-latch guards prevent silent consumption across error boundaries.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-25 19:43:29 -07:00