Typing a path like `Documents/orca-internal` in the browse filter now resolves the path on the remote instead of producing "No matches". Supports `~`, absolute, and relative paths with live preview and Enter to navigate.
Co-authored-by: Orca <help@stably.ai>
* fix(cmd-j): rank empty-query worktrees by focus recency
Persist a per-worktree focus-recency timestamp and use it as the primary
ordering signal for Cmd+J's empty-query Worktrees section, so SSH and
other quiet worktrees surface based on user focus rather than background
activity. See docs/cmd-j-empty-query-ordering.md.
Co-authored-by: Orca <help@stably.ai>
* fix(tests): guard lastVisitedAtByWorktreeId and mock markWorktreeVisited
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Deleting an unselected, scrolled-past worktree snapped the sidebar back
toward the selected item. The virtualizer's viewport key includes row
count so it can remount cleanly when rows change (avoiding stale height
measurements that caused card overlap) — but the fresh mount started at
scrollTop 0.
Persist the scroll offset in a parent-owned ref, seed the new
virtualizer via initialOffset, and re-apply scrollTop in a
useLayoutEffect with a retry loop that tolerates the transient window
where the browser clamps scrollTop while the virtualizer is still
measuring rows.
Co-authored-by: Orca <help@stably.ai>
* 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>
Renames the experimental pet overlay to "sidekick" with themed character
names (Claude the Mage, OpenCode the Rogue, Gremlin the Trickster).
Covers IPC channels, preload API, persisted UI state, settings flag,
on-disk userData path, components, and types.
Deletes the standalone pet-overlay design mock.
Co-authored-by: Orca <help@stably.ai>
* feat: add experimental pet overlay
Adds an opt-in 3D pet overlay pinned to the bottom-right. Gated behind
an experimental flag so three.js + GLB models stay out of the renderer
bundle for users who never enable the feature. Ships with four bundled
models plus user-uploaded custom GLBs via a pet:import IPC; a status-bar
segment provides model picker + hide toggle.
See docs/design/pet-overlay.md for the full design.
Co-authored-by: Orca <help@stably.ai>
* refactor(pet): replace 3D GLB models with 2D webp images
Co-authored-by: Orca <help@stably.ai>
* chore(pet): compress pet webp images and remove unused assets
Reduce resources/claude.webp, gremlin.webp, opencode.webp sizes; drop
the obsolete pet-overlay design doc and compress-pet-glb script now
that the GLB pipeline has been replaced by 2D webp images.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): align tab row bottom with sidebar header
The center column's top band was 6px drag strip + 34px tab row = 40px,
while the sibling `titlebar-left` above the sidebar is 42px. The tab row
sat 2px high, making the seam between the two columns visibly off.
Bump the drag strip from 6px to 8px so the top band totals 42px and the
tab row's bottom border lines up with the sidebar header.
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): shrink tab row to 32px, pad drag strip to 10px
Keep the total top-band at 42px (matching the sidebar's titlebar-left)
while making the tabs themselves 2px shorter, so the tab chrome feels a
touch more compact without breaking alignment with the sidebar header.
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): drop doubled border on leftmost split pane
The TabGroupSplitLayout wrapper paints a full-height `border-l` at the
sidebar seam, and every split pane paints its own `border-l`. On the
leftmost pane those stacked at the same x, reading as a ~2px bar just
below the drag strip where the wrapper's 1px line continues alone above.
Mirror the right-edge handling: skip the pane's `border-l` when it
touches the left edge, leaving the single wrapper border to draw the
seam — same visual weight as the right edge.
Co-authored-by: Orca <help@stably.ai>
* docs(tabs): refresh stale wrapper border-l comment
Why: `border-l` on the wrapper comment still described the pre-fix
overlap behavior. After `touchesLeftEdge`, the leftmost pane drops
its own `border-l`, so the seam is exactly 1px — the old comment
contradicted the code.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
- BrowserTab: Globe icon uses text-blue-500 on both active and inactive tabs
for a distinct, recognizable anchor in the tab strip.
- shell-icons: GenericTerminalIcon now renders a pitch-black tile with a
thick stroked >_ (chevron at 3.2px, underscore at 2.6px, taller chevron)
so mac/linux/default terminal tabs match the colored-badge treatment of
the PowerShell/CMD/WSL icons instead of a flat lucide chevron.
- SortableTab: all terminal tabs use ShellIcon. On Windows, tabs without a
per-tab shellOverride fall back to the user's configured default
Windows shell so the tab-strip icon reflects what's actually running.
Co-authored-by: Orca <help@stably.ai>