* fix(runtime): kill all PTYs for a worktree on removal (design §4.3)
Worktree deletion only shut down renderer-tracked terminals, so PTYs owned
by background tabs, split panes, or pre-reload sessions survived the
removal and kept leaking memory. Introduce killAllProcessesForWorktree
with three sweeps (runtime leaves, provider-prefix scan of daemon session
ids, pty-registry by worktreeId) and wire it into both teardown paths:
the CLI-initiated removeManagedWorktree and the renderer-initiated
worktrees:remove IPC handler. OrcaRuntimeService gets a lazy
getLocalProvider thunk so construction order stays robust.
Co-authored-by: Orca <help@stably.ai>
* fix(renderer): purge worktree-scoped state on removal + hydration (design §4.4)
When a worktree is deleted, ~25 worktree-scoped maps (tabsByWorktree,
git caches, browser state, split-tab models, per-file editor drafts,
etc.) kept references to the gone worktree, so SessionsStatusSegment
kept mis-classifying orphaned PTYs as bound and dropdowns rendered stale
ids. Add purgeWorktreeTerminalState as a single atomic action that
wipes every scoped map plus cascades top-level actives. Fire it from
the worktrees:changed listener on the set-diff of removed ids, and once
more at hydration via fetchAllWorktrees to clean up persisted entries
from pre-fix sessions. The hydration-time purge is gated behind a
per-repo success check: a single transient IPC error or an all-empty
fetch defers the purge so a degraded launch cannot wipe legitimate
persisted state.
Co-authored-by: Orca <help@stably.ai>
* test(zombie-worktree): regression coverage for design §4.5
Adds tests for every layer of the zombie-worktree fix:
- worktree-teardown: unit coverage of the three-sweep helper including
best-effort error swallowing across provider/registry.
- orca-runtime: RPC-initiated removeManagedWorktree kills PTYs before
any git mutation + verifies the lazy getLocalProvider thunk resolves
on each call.
- worktrees IPC: renderer-initiated remove kills PTYs before git and
skips the kill helper for SSH-backed repos.
- renderer slice: fetchAllWorktrees defers the purge when any sibling
repo fetch fails or every repo returns empty (F1 regression);
happy-path fires the purge once and does not re-run on subsequent
calls. Direct purgeWorktreeTerminalState coverage pins the cascade
across worktree-keyed, tab-id-keyed, and file-id-keyed maps.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): log worktree-teardown kill counts (design §4.4 observability)
Breadcrumb lets ops distinguish a renderer-state-induced leak (diff-path
purge non-empty) from a backend-induced one (nothing to kill but memory
still pinned). Emit only when the sweep actually shut anything down so
steady-state logs stay quiet. Added at both call sites —
removeManagedWorktree (CLI path) and the worktrees:remove IPC handler.
Co-authored-by: Orca <help@stably.ai>
* test(zombie-worktree): fix ptyIdsByTabId seed shape to match production type
The purge unit test seeded ptyIdsByTabId as Record<string, string> when
the runtime type is Record<string, string[]>. The unit tests passed
because they never hit the UI renderer, but live e2e surfaced a
TypeError: (ptyIdsByTabId[tabId] ?? []).some is not a function.
Corrected to arrays; 21/21 tests still pass.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Partially reverts #1359: drop the hideIdentityIcon flag on the inline
agent list so the Claude/Gemini/… glyph renders again. The expand
chevron stays hidden — clicking the row still jumps straight to the
agent, and the chevron was the redundant part. Identity is useful when
a worktree has more than one agent of different types.
Co-authored-by: Orca <help@stably.ai>
* fix(pty): release ptmx fd on natural exit + defuse SIGHUP-to-recycled-pid
Daemons accumulated ptmx fds over time because node-pty's UnixTerminal
only releases the master fd when destroy() runs. On the natural-exit
path (the common case — user closes a tab, shell runs `exit`) nothing
ever calls destroy(), so the fd leaks until GC. On macOS this
eventually hits kern.tty.ptmx_max=511 and all new terminals fail to
spawn.
Fix: release the fd synchronously on every teardown path (natural
exit, explicit kill, stale SSH spawn, daemon shutdown) and close the
concurrent SIGHUP-to-recycled-pid hazard inside node-pty's
UnixTerminal.destroy().
- src/main/daemon/pty-subprocess.ts: synchronous POSIX proc.kill
neutralization inside proc.onExit; dead guards on forceKill/signal
so they never target a reaped-and-possibly-recycled pid
- src/main/daemon/session.ts: new disposeSubprocess() for already-
exited sessions (fd release only, no SIGKILL) — avoids sending
SIGKILL to a recycled pid during daemon shutdown
- src/main/daemon/terminal-host.ts: dispose loop routes on isAlive —
live sessions get forceKillAndDisposeSubprocess (SIGKILL + fd
release), exited sessions get disposeSubprocess (fd release only)
- src/main/providers/local-pty-provider.ts: same POSIX kill
neutralization at top of onExit for the legacy local path
- src/relay/pty-handler.ts: same neutralization in wireAndStore;
disposed flag guards all public entry points; dispose() uses
SIGKILL (not SIGTERM) before destroy since the relay is exiting;
killTimer fallback + immediate-shutdown + stale-spawn cleanup all
call disposeManagedPty + ptys.delete so wedged children (D-state,
bad NFS) can't leak map entries against the 50-PTY cap
Windows is exempt everywhere — WindowsTerminal.destroy IS a kill()
call internally (closes the ConPTY agent), so neutralizing would
turn destroy into a no-op and leak the agent.
See docs/fix-pty-fd-leak.md for the full design.
Co-authored-by: Orca <help@stably.ai>
* fix(pty): patch node-pty native off-by-one leaking /dev/ptmx per spawn
node-pty 1.1.0's pty_posix_spawn on macOS walks low_fds[0..2] in an
allocation loop that breaks at the first fd >= STDERR_FILENO, then
cleans up via `for (; count > 0; count--) close(low_fds[count])`. In
the typical case (break at count=0) the cleanup body never runs and
low_fds[0] — a /dev/ptmx handle — leaks per spawn. Fixed upstream in
microsoft/node-pty af053f2 (PR #882), not in any 1.1.0 release.
Backport the 3-line cleanup-loop fix as a pnpm patch. E2E validated
against a dev daemon: 200 spawn/kill cycles kept the daemon's ptmx
fd count flat at baseline; prior runs reproduced linear 1-per-spawn
growth. Also documents the native root cause as a status addendum in
docs/fix-pty-fd-leak.md — the JS-side destroy() discipline previously
landed is still load-bearing for the SIGHUP-to-recycled-pid hazard and
for synchronous fd release on daemon shutdown.
Co-authored-by: Orca <help@stably.ai>
* fix(pty): capture stable kill spy ref in pty.test.ts
destroyPtyProcess reassigns proc.kill = () => {} on POSIX to defuse
the SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). After
that reassignment, proc.kill.mock is undefined and the assertions
crashed in CI. Capture a stable reference to the vi.fn() before it
gets reassigned.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(daemon): guard killStaleDaemon against pid recycling
The daemon's pid file carried only a bare integer, so killStaleDaemon had
no way to verify the current owner of that pid was still the process it
forked. Unix pids recycle — on macOS the default ceiling is ~2048 and
wraps in minutes under load — so blind SIGTERM/SIGKILL on a recycled pid
could hit an unrelated user process (editor, language server, background
task) silently.
Three moving pieces:
1. Pid file now carries startedAtMs. daemon-spawner exports DaemonPidFile
and serializeDaemonPidFile; daemon-init fills startedAtMs from
getProcessStartedAtMs(child.pid) right after the daemon signals ready.
parseDaemonPidFile tries JSON first and falls back to bare-integer for
backward compatibility (startedAtMs: null on legacy files).
2. isDaemonProcess takes expected startedAtMs. In addition to the cmdline
match, it consults getProcessStartedAtMs(pid) and compares with a
±1.5s tolerance. Null on either side is fail-open so the guard never
strengthens existing behavior negatively (Windows, legacy pid files,
kernel-thread /proc failures).
3. SIGKILL re-check. The SIGTERM-then-wait window is up to 3s — long
enough for the pid to be recycled if the original daemon dies during
the wait. Before escalating to SIGKILL, isDaemonProcess runs again;
on mismatch we log reason=pid_recycled and skip SIGKILL.
Carried forward from #1323 as a standalone safety fix — independent of
that PR's router/drain machinery, which is not shipping.
Co-authored-by: Orca <help@stably.ai>
* test(daemon): cover pid-recycling guard surface in daemon-health
Adds unit coverage for the new Phase 0 surface:
- parseDaemonPidFile: JSON round-trip, JSON without startedAtMs,
bare-integer fallback for legacy pid files, malformed-input rejection.
- startTimeMatches: null-expected fail-open, null-actual fail-open,
within-tolerance match, outside-tolerance rejection.
- killStaleDaemon: with a mismatched startedAtMs in the pid file,
assert that no SIGTERM/SIGKILL is sent even though the liveness probe
(process.kill(pid, 0)) runs.
startTimeMatches was promoted from module-private to exported so it can
be exercised directly — it's a pure function with no internal state.
Co-authored-by: Orca <help@stably.ai>
* feat(settings): manage sessions panel for daemon staleness UX
Add a Manage Sessions settings panel with list/kill-all/kill-one/restart
backed by a new pty:management IPC surface. Rows are hover-highlighted and
click to reveal the corresponding terminal pane, mirroring the bottom
status-bar sessions popover. Kill-all and restart-daemon are icon buttons
(Trash2, RotateCw) with tooltips so the restart action doesn't collide with
the row-refresh RefreshCw icon.
Co-authored-by: Orca <help@stably.ai>
* fix(settings): honest killAll counts + suppress post-kill spawn toast
killAll now snapshots the initial session IDs and polls listSessions every
100ms for up to 6.5s — past the daemon's 5s SIGTERM→SIGKILL ladder — so
well-behaved shells hosting long-running agents finish their SIGTERM
handlers before we classify them as "refused to exit." Shutdowns fire once
per initial session (no retry spam), and fresh session IDs that appear
mid-poll (renderer remounts) don't inflate remainingCount.
pty-transport's connect() catch now detects the adapter's
TerminalKilledError tombstone rejection ("...was explicitly killed") and
suppresses the red "file an issue" toast. After Kill All, a pane remount
would call pty:spawn on the dead session ID; surfacing the tombstone as
a scary error misrepresented an intentional user action. The pane still
renders "Process exited" via the normal lifecycle.
Co-authored-by: Orca <help@stably.ai>
* feat(daemon): foundation for pty:management IPC surface
Adds the plumbing the Manage Sessions settings panel depends on:
- daemon-init exports getDaemonProvider / replaceDaemonProvider /
restartDaemon / cleanupDaemonForProtocol so the pty:management
handlers can access the current provider and coordinate a clean
restart without importing window-services internals.
- daemon-pty-router exports getAllAdapters so the killAll / listSessions
handlers can fan across the current adapter plus any legacy-protocol
adapters still attached for in-flight sessions.
- daemon-pty-adapter gains a listSessions RPC and readonly
protocolVersion so the handlers can annotate each session with the
adapter it belongs to and route killOne back to the right adapter.
- types.ts exports DaemonSessionInfo (SessionInfo + protocolVersion)
as the shared shape the preload API surface mirrors.
- ipc/pty.ts, attach-main-window-services, TerminalPane and
terminal-search pick up the small bindings required to wire the
router through existing code paths without regressions.
Co-authored-by: Orca <help@stably.ai>
* fix(settings): remove high-session-count warning banner
The banner nagged at 20 sessions, which is well within normal use for
users with many open worktrees. Count is already visible in the header
bar, and the table supports per-row and bulk kills, so the banner added
noise without actionable value.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): add single-instance lock + owned-metadata clear to prevent orca-runtime.json corruption
Closes#1312.
Every AppImage/.app relaunch was booting a fresh Electron main that clobbered
`<userData>/orca-runtime.json` and `agent-hooks/endpoint.env`. When the newest
instance quit, metadata pointed at a dead pid and `orca status` reported
`stale_bootstrap` even though the original Orca was still running. SIGKILL'd
predecessors also left orphaned `o-<pid>-*.sock` files in userData.
Three surgical changes:
1. `app.requestSingleInstanceLock()` in a new
`src/main/startup/single-instance-lock.ts` helper, wired into
`src/main/index.ts` after `configureDevUserDataPath(is.dev)` so dev and
packaged runs lock in separate namespaces. Losing instances focus the
primary's window via `second-instance` and quit without touching userData.
2. `clearRuntimeMetadataIfOwned(userData, pid, runtimeId)` in
`runtime-metadata.ts` — compares both pid AND runtimeId against the
current file before clearing, so the auto-updater handoff window never
erases the replacement process's fresh bootstrap. Called from a rewritten
`will-quit` handler that folds `runtimeRpc.stop()` + owned-clear into the
same `Promise.allSettled([disconnectDaemon, …]).then(app.quit)` chain
(inside the `!daemonDisconnectDone` guard so the second-pass re-entry
can't re-invoke stop+clear).
3. `sweepOrphanedRuntimeSockets()` in `runtime-rpc.ts` runs at the top of
`start()` on POSIX, using `process.kill(pid, 0)` to probe liveness and
remove `o-<dead-pid>-*.sock` orphans left by SIGKILL/OOM-kill.
Tests (37 new/updated):
- `single-instance-lock.test.ts` (3): lock-failed does not register listener;
lock-acquired registers exactly one; callback dispatches correctly.
- `runtime-metadata.test.ts` (+4): clearRuntimeMetadataIfOwned matched /
pid-mismatch / runtimeId-mismatch / no-file branches.
- `runtime-socket-sweep.test.ts` (4): own-pid-skip / alive-retain /
dead-sweep / regex-miss separated via synthetic ownPid=1; two
regex-invariant tests assert the sweep regex matches the real
`createRuntimeTransportMetadata` output (including the 'rt' fallback).
Design doc: `docs/fix-missing-single-instance-lock.md`.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): focus hidden windows on second-instance event
focus() alone is a silent no-op when the primary window is hidden
(close-to-tray on macOS via Cmd+W, or on a different macOS Space) or
behind other apps on Windows. Call show() before focus() so a second
launch attempt reliably surfaces the existing window regardless of
state.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(browser): viewport-size emulation via CDP
Adds a Viewport Size submenu in the browser toolbar "…" menu.
Presets apply width/height/deviceScaleFactor/mobile/touch via
Emulation.setDeviceMetricsOverride + setTouchEmulationEnabled, and
swap the UA to a mobile iPhone CriOS string on mobile presets.
Responsive clears the override. Selection persists per-tab and
re-applies on dom-ready so it survives navigations.
Co-authored-by: Orca <help@stably.ai>
* chore(browser): rename 'Responsive' viewport option to 'Default'
Co-authored-by: Orca <help@stably.ai>
* fix(browser): harden viewport emulation — serialize, validate, client-hints
- Chain per-tab setViewportOverride calls to prevent rapid-toggle races
- Validate viewport metrics at IPC trust boundary (reject non-finite/out-of-range)
- Emit userAgentMetadata alongside mobile UA to avoid UA/CH mismatch
- Always reapply on dom-ready (incl. null) to clear stale emulation
- Persist viewportPresetId in session schema (optional+nullable for back-compat)
- Convert preset submenu to DropdownMenuRadioGroup for a11y
- Log debugger.attach failures and cover with a unit test
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(hooks): generate worktree setup-runner from target worktree's orca.yaml
Extend getEffectiveHooks, getSetupCommandSource, and runHook with an
optional worktreePath parameter. When provided, loadHooks reads the
yaml from that path; otherwise it falls back to repo.path. Update
the worktree-creation paths in worktree-remote.ts and orca-runtime.ts
to thread the new worktreePath through after the worktree exists, so
the generated setup-runner reflects the yaml at the tip of the target
worktree's branch instead of the primary checkout's stale yaml.
Add a regression test in hooks.test.ts that mocks two distinct
orca.yaml files (primary and worktree) and asserts the worktree's
content wins when worktreePath is passed.
The legacy hooks:check IPC handler keeps reading from repo.path
unchanged.
Closes#1256
* fix(hooks): skip auto-setup when worktree script differs from preview
Add setupScriptsMatch helper that compares the primary checkout's
setup script (what the renderer shows the user before worktree
creation) to the target worktree's script (what would actually run
after creation). When they differ, createLocalWorktree skips the
auto-launch and logs a warning, so a base-branch yaml that introduces
or modifies setup commands cannot execute under the trust granted to
the primary's preview. CLI-created worktrees use the worktree-bound
load directly because trust is granted by the CLI invocation context,
which is annotated in orca-runtime.ts.
Adds regression coverage for both matching and differing script cases.
* test(hooks): add setupScriptsMatch to worktree IPC test mocks
The new setupScriptsMatch import in worktree-remote.ts means the
existing vi.mock('../hooks') blocks in worktrees.test.ts and
worktrees-windows.test.ts now need to expose it. Default the mock to
returning true so existing tests continue to exercise the run-setup
path; the new behavior gating is covered by the dedicated
setupScriptsMatch unit tests.
Register a window-level keydown listener while the SSH disconnected
dialog is open so Enter triggers Reconnect regardless of which element
holds focus. Dialog-scoped handlers don't fire when focus is inside
xterm/monaco, which aggressively reclaim focus.
Co-authored-by: Orca <help@stably.ai>
* wip
* refactor(sidebar): drop agent identity icon + expand chevron from inline rows
In the per-card inline agent list, the user already knows which worktree
they're looking at and clicking a row jumps directly to the agent — the
repeated identity glyph (Claude/Gemini/…) and a separate expand chevron
are redundant. Add hideIdentityIcon/hideExpand props to DashboardAgentRow
(default false) and set both on the inline list only; full dashboard is
unchanged.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat: close settings on Escape key press
* fix(settings): skip Escape-to-close when focus is in editable field
Why: Escape in an input/textarea/select or contenteditable region usually
means 'cancel this edit', not 'close Settings'. Closing the entire page
would discard in-progress typing. Defer to the field's own handler; a
subsequent Escape with focus on the body will close the page.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Allows the user to reposition the sidekick overlay by pressing and dragging. Position is clamped to the viewport (including on resize) and persisted to localStorage. Uses pointer capture so drag state can't get stuck if the pointer is released outside the window.
Co-authored-by: Orca <help@stably.ai>
release.yml's publish-release job got `actions: write` in f6cc2aee so it
can dispatch homebrew-bump.yml, but reusable-workflow permissions are
capped by the caller. release-cut.yml and release-rc.yml only listed
`contents: write`, so the first cut after f6cc2aee died with
startup_failure (run 25244518794) before any job could start.
Grant `actions: write` on both callers to match what release.yml needs.
Co-authored-by: Orca <help@stably.ai>
Two bugs surfaced on the v1.3.26 release:
1. release.yml's new `Trigger Homebrew cask bump` step failed with
HTTP 403 "Resource not accessible by integration". The publish-release
job's permissions block only listed `contents: write`, but
POST /actions/workflows/:id/dispatches requires `actions: write` on the
scoped-down GITHUB_TOKEN. Added the permission.
2. Even when the bump workflow does run, its `gh pr merge --auto` was
silently no-op'ing against the homebrew-orca tap, leaving bump PRs
open. `--auto` only activates when there's a required check or
branch-protection rule to wait on; the tap intentionally has neither.
Switched to a direct squash-merge so the tap stays hands-free as
originally intended.
Co-authored-by: Orca <help@stably.ai>
If a cut pushes the tag but the workflow is cancelled (or the dependent
release.yml job otherwise fails to start) before a GitHub release is
published, every subsequent patch cut recomputes the same version and
dies on "Tag already exists." This wedged the pipeline on 2026-05-01
when v1.3.26 was pushed by cancelled run 25237882049 — patch cuts kept
failing for hours until release.yml was dispatched by hand.
When the tag exists but no published release is attached, skip the
bump/push steps and emit the existing tag as the job output so
release.yml runs against it and finishes what the prior attempt
started. Still refuse the collision when a real published release
exists — that's a genuine conflict (e.g. a hand-tagged version) and
needs human attention.
Co-authored-by: Orca <help@stably.ai>