* fix(activity): polish thread-row hover, restore stable layout, widen list
The activity thread list had several hover regressions: the cursor wasn't
indicating clickability, hovering shifted the row contents because the
bell-toggle replaced the timestamp under the cursor, and the timestamp
tooltip could get stuck open after the trigger faded out (Radix didn't
dismiss because opacity:0 left it interactable). The Jump-to-workspace
button in the right pane header also read as easy-to-miss chrome.
Surface per-card actions on hover (Open + Mark-unread) in a reserved
slot so the worktree-name's flex width doesn't reflow when the buttons
appear. Drop the redundant header button. Bump the default thread list
width 340 → 480px (range 320–720) and let prompts wrap to 3 lines so
the cards work as the primary surface — the terminal is supplementary.
Mark-unread only appears for already-read threads (selecting an unread
thread auto-marks it read, so a "mark read" button would be redundant).
The remove-events-count badge change was already staged locally and is
included to keep the row chrome consistent with the new layout.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): replace 'Open' label with ExternalLink icon-only button
Co-authored-by: Orca <help@stably.ai>
* fix(activity): move Mark-unread into the bell slot on hover
Co-authored-by: Orca <help@stably.ai>
* fix(activity): match WorktreeCard bell pattern in thread rows
Co-authored-by: Orca <help@stably.ai>
* fix(activity): drop AGENTS heading, add overflow menu with Mark all read
The 'AGENTS' uppercase header was redundant — the activity titlebar
already names the surface. The 'Mark all read' button was also stuck in
the titlebar far from the threads it acts on.
Drop the heading and surface 'Mark all read' as a per-list overflow menu
(MoreVertical) next to the Filter input + unread-only toggle. The action
disables when there's nothing unread to mark.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(tasks): remove duplicate top band on Tasks page
The Activity-page work in #1703 tightened App.tsx's `workspaceActive` to
require `activeView === 'terminal'`, so App.tsx now keeps its full-width
titlebar on the Tasks page. TaskPage's own `workspaceActive` was still
just `activeWorktreeId !== null`, so it kept rendering its own 36px
placeholder strip below the App titlebar — producing the doubled band.
TaskPage only mounts when `activeView === 'tasks'`, where App.tsx never
hides its titlebar, so the placeholder strip is always redundant. Drop
it along with the now-unused `sidebarOpen` / `activeWorktreeId` reads.
Co-authored-by: Orca <help@stably.ai>
* fix(tasks): remove stale titlebar comment
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(activity): isolate the agent's pane in the activity terminal view
When the activity page hosts a tab that has split panes, only the agent's
specific pane should be visible — not the whole tab. Implemented as a
transient layout override using the existing applyExpandedLayoutTo helper
with a separate snapshot ref, so it never touches the user-facing
expanded-pane state or the persisted layout snapshot. Closing the
activity page restores the original split layout untouched.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): pass isolatedPaneId from the live tab-group render path
TabGroupPanel renders the actual TerminalPane in tab-group mode (the
default modern path); Terminal.tsx is the legacy single-group fallback.
Without this, isolatedPaneId never reached TerminalPane, so the activity
view kept showing all split panes side-by-side.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): refit terminals when isolation override is removed
The activity-isolation useLayoutEffect only called safeFit on the apply
path, so removing isolation (or hitting the early-return when
applyExpandedLayoutTo returned false) left xterm sized for the
single-pane geometry until an unrelated event triggered another fit.
Extract scheduleRefit() and run it from all three branches.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* Move mobile settings to beta section
Co-authored-by: Orca <help@stably.ai>
* Add mobile setup guidance
Co-authored-by: Orca <help@stably.ai>
* Use global App Store URL for mobile
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(activity): shrink thread-row title and stop branch-name truncation
Move the time + count + unread cluster up onto the title row so the
secondary row is full-width for the repo badge and branch name (long
branch names were being clipped by the right cluster). Also drop the
title from 13px to 11px and the branch metadata to match.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): swap "New" badge for BellDot icon on unread thread rows
The bell-with-dot already represents unread elsewhere (filter toggle,
thread-row hover swap), so the text badge was redundant.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): cluster unread BellDot in the top-right with timestamp
Match the position of the existing hover-bell so unread cues live in the
row's time/action column instead of inline with the title text.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): fill the unread bell and merge it into the timestamp slot
The static BellDot now lives in the same right-most slot as the timestamp,
so on hover the slot's contents (bell + time) fade out and only the
hover-toggle button fades in — no more double-bell on hover. Also
fill="currentColor" so the unread bell reads as a solid indicator.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(runtime): compare mobile session sync key by reference instead of stringifying large maps
`getRuntimeMobileSessionSyncKey` used to `JSON.stringify` `terminalLayoutsByTabId` and `runtimePaneTitlesByTabId` whole, which scaled with the lifetime accumulation of tabs in a workspace and pinned the main thread for ~750ms per click in workspaces with hundreds of tabs while an agent was working. The maps reallocate on real changes and stay reference-stable otherwise, so the key now holds them by reference and only pre-serializes the small projected shapes that need value-level comparison. Subscriber gate also early-returns when those two maps are reference-stable, so the common click path (`updateTabTitle` reallocating `tabsByWorktree`) skips the key build entirely.
See docs/agent-working-pane-typing-lag.md for the trace and root-cause writeup.
Co-authored-by: Orca <help@stably.ai>
* docs: correct misleading comment on the relevant-fields gate
The earlier comment claimed adding terminalLayoutsByTabId /
runtimePaneTitlesByTabId to the gate prevented updateTabTitle from
falling through. That's wrong — updateTabTitle reallocates
tabsByWorktree, which is a separate field already in the gate and
already fails the AND-chain. The two added fields actually catch a
different path: layout-only mutations like setTabLayout that don't
touch any other gate field. Comment now describes the real intent:
the gate is a strict superset of every input to
getRuntimeMobileSessionSyncKey, so passing it guarantees the key is
unchanged without materializing one.
Doc updated to match.
Co-authored-by: Orca <help@stably.ai>
* chore: drop perf writeup from branch (kept in commit history)
Co-authored-by: Orca <help@stably.ai>
* test(runtime): pin down by-reference invariant of mobile sync key comparator
Reworks the existing reference-stable test to use two distinct AppState instances sharing every comparator-checked map, and adds a negative test that detects a regression to deep equality on terminalLayoutsByTabId. Extracts a makeSharedOverrides() helper so tests can isolate a single field without makeState's fresh-default churn defeating the assertion.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(activity): align unread dot with row icon
Anchor the unread mini-dot to the icon's own bounding box (relative
inline-flex with -top-1 -left-1) instead of the 24x24 cell. The previous
absolute positioning was relative to the cell, so the dot landed visibly
off-center against the centered AgentStateDot/Plus glyph. Adds a
ring-2 ring-background halo for contrast against the row's tinted unread
background. Applies to both AgentEventRow and WorktreeEventRow.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): drop top padding so content sits flush with titlebar
Outer wrapper used py-3, plus the right-pane <section> had pt-2 and the
thread-list header had py-2 — the stacked top whitespace produced a
visible gap above the first row against the titlebar. The
ActivityTitlebarControls bar already provides the breathing-room band.
Drop the wrapper's top padding (py-3 → pb-3), drop the right-pane
section's pt-2, and tighten the thread-list header to pt-1.5 pb-2 so the
WORKTREES label aligns with the right-pane worktree title row.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): make event row the click target for navigate + ack
The inline "Jump to agent" button only rendered when agentAlive was
true, so retained-done rows had no jump affordance, and in compact mode
it was hover-only. Users naturally click anywhere on the row expecting
it to navigate.
Move the navigate-and-ack logic onto the row's onClick (with role,
tabIndex, and Enter/Space keyboard support) and drop the inline buttons.
Per the design doc (option 1), drop the agentAlive gate entirely:
activateAndRevealWorktree is safe unconditionally and
activateTabAndFocusPane silently no-ops on a missing tab id, so a
stale-tab click is a soft no-op.
Mirror the same pattern on WorktreeEventRow so clicking a "Worktree
created" row navigates to that worktree.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): bell glyph for unread + larger row state icon
The unread mini-dot stacked on the AgentStateDot competed with the dot
itself for the eye and read like a status badge on the agent state.
Replace it with a small BellDot glyph at the icon's top-right corner so
the unread cue rhymes with the bell button on ThreadRow — one unread
vocabulary across both surfaces.
While here, bump the row state icon to a new 'lg' AgentStateDot size
(18px) so the green check sits center-of-mass in its 32px column instead
of floating small and high. Bump the WorktreeEventRow Plus glyph to
match.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): drop left padding so thread list reaches the window edge
The page wrapper used px-4, which left a 16px gap to the left of the
thread list — visually inconsistent with how sidebars abut the window
chrome elsewhere. Switch to pr-4 (keep right padding for the right
pane). Inner thread-list and right-pane padding remain unchanged.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): use left-edge bar for unread instead of an icon overlay
Mini-dot and bell-glyph attempts both crowded the AgentStateDot/Plus
icon column. Switch to the same left-edge primary bar that ThreadRow
already uses for unread — a row-level cue keeps the icon column clean
and unifies the unread vocabulary across both panes.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): page extends to both edges, restore right-pane top padding, rename to "workspace"
- Drop horizontal padding on the page wrapper (pr-4 → none) so the
thread list reaches the left edge and the right pane reaches the
right edge — matches how sidebars abut the chrome elsewhere.
- Restore a small top padding (pt-2) on the right-pane title row so
the workspace heading isn't pinned to the titlebar. Earlier Fix 2
over-corrected by removing it entirely.
- Rename user-facing "Worktree(s)" → "Workspace(s)" (column label,
event title "Worktree created", description copy, "Jump to
worktree" button, empty state). Internal identifiers stay as
worktree* since that's the data-model name.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): remove redundant "Jump to workspace" header button
Clicking any event row already calls markThreadRead +
activateAndRevealWorktree (plus a tab focus, so it's a strict superset
of what the header button did). The button's only unique behavior was
"go to the workspace without picking a tab" — thin justification when
the latest event is one row down. cursor-pointer + hover already signal
that rows are clickable.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): wrap agent summary instead of truncating
The summary line used 'truncate' with a max-width, hiding the rest of
the agent's message behind an ellipsis. The Activity page is meant to
let users scan what each agent said without leaving the surface, so
truncation defeats its purpose. Switch to break-words +
whitespace-pre-wrap so the full message renders and multi-line output
keeps its line breaks.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): clamp agent summary to 3 lines
Showing the full message made tall rows for chatty agents. Cap at 3
lines (line-clamp-3) — long enough to convey what happened, short
enough that rows stay scannable. Users open the workspace if they want
the full transcript.
Co-authored-by: Orca <help@stably.ai>
* feat(activity): switch left list from workspaces to agent panes
Restructure the activity feed so each thread is one *agent pane* (a
terminal tab + pane id) rather than one workspace. Internal model goes
from WorktreeThread → AgentPaneThread keyed on paneKey.
Renderer changes:
- Left list: each row shows the pane title (with agent icon + repo
badge), then the workspace name as secondary context. Section label
changes "Workspaces" → "Agents".
- Right pane: header shows the pane title, agent icon, repo badge, and
the workspace name beneath. Drops the Today/Yesterday/Earlier day
grouping per design — the relative timestamp on each row is enough
orientation, and a flat chronological list scans more cleanly.
- Drops the workspace-created event kind entirely (and the
WorktreeEventRow + ActivityRow union dispatcher), since the surface
is now strictly agent activity.
Side effects:
- Mark-read no longer needs the locallyReadEventIds layer or the
worktree-unread store calls (markWorktreeUnread/clearWorktreeUnread).
Acknowledge by paneKey is the only persistence path.
- Removes Plus and groupForTimestamp (unused after the refactor).
Co-authored-by: Orca <help@stably.ai>
* feat(activity): label panes like the per-workspace agents dropdown
Pane labels now follow the same hierarchy DashboardAgentRow uses inside
the WorktreeCardAgents dropdown:
customTitle (user rename) > non-default OSC title > last prompt
> defaultTitle / "Terminal"
The prompt fallback is the important one — agents that haven't been
renamed and don't set an OSC title now render with what the user asked
the agent to do (e.g. "Fix the unread dot alignment") instead of the
generic "Terminal 1" placeholder. Matches the visual + naming pattern
on each workspace card so the activity page reads as a flat-feed
extension of that surface.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): smaller titles, allow 2-line prompt clamp
Long prompts were getting truncated at one line, hiding most of the
ask. Switch the thread row title from truncate to line-clamp-2, with
break-words so long words wrap, and tighten size to text-[13px] +
leading-snug so two clamped lines don't dominate the row vertically.
Anchor the agent icon to the first line via items-start + pt-[3px].
Right-pane heading drops text-base → text-sm to match.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): right-pane title gets the same 2-line clamp + smaller size
Mirror the ThreadRow change on the right-pane heading: line-clamp-2 +
break-words + leading-snug so a long prompt title shows two lines
instead of a single-line ellipsis. The repo badge moves down to the
secondary line (next to the workspace name) so it doesn't shift with
the title height.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): match WorktreeCard's selected/hover/unread cues
Three stacked tints (selected + unread row tint + hover) made an unread
hovered row look identical to a selected row, and hover even fought
selected. Mirror WorktreeCard:
- selected → solid black/white tint + faint shadow, hover suppressed
(the active class wins so the row stays visually fixed)
- non-selected → only then does hover apply (bg-accent/40)
- unread → weight + left-edge primary bar carry the cue; row tint
removed (matches WorktreeCard's "weight alone carries the unread
signal")
Co-authored-by: Orca <help@stably.ai>
* feat(activity): portal selected agent terminal
Co-authored-by: Orca <help@stably.ai>
* fix(activity): keep completed entries after terminal input
Co-authored-by: Orca <help@stably.ai>
* fix(activity): tighten portal target, unread count, and selection edges
- activity-terminal-portal: replace document.body MutationObserver with a
module-level pub/sub registry; the page publishes the target via a ref
callback, Terminal subscribes. Removes body-wide DOM observation.
- Terminal: memoize activityTerminalPortal so WorktreeSplitSurface's
React.memo bail-out is preserved across unrelated Terminal re-renders.
- ActivityPrototypePage: cap events per-pane (5) so a chatty pane can't
push quiet panes off the left list; render an empty-state when a
retained thread's tab is gone; skip workspace mutation in
activateThreadTerminal when the tab is no longer live; keep the
selected thread visible in unread-only mode after auto-mark-read.
- ActivityTitlebarControls: walk stateHistory in the unread accumulator
(mirrors the new feed semantics); drop worktree-created counting since
the new feed has no surface to dismiss them.
- AgentStateDot: remove the dead 'lg' size variant.
- App: hide worktree sidebar on the Activity view and include it in the
back/forward shortcut + button cluster.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): double-buffer portal slots to remove cold-mount flash
Switching threads on the Activity page used to expose xterm's empty
canvas for ~133ms while a newly-mounted TerminalPane attached. Replace
the single portal target with a double-buffered set of slots: keep the
previously displayed terminal visible while the next selected terminal
mounts in an invisible same-size slot, then flip slots once the staged
slot contains the selected terminal DOM with xterm's screen and PTY
binding (with one extra frame only after observing an empty mount).
Skips publishing a portal target when the selected retained thread no
longer has a live tab so the previous tab's target doesn't linger.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): remove single-option Display style settings dropdown
The activity page's gear menu only held a "Compact list" toggle. Drop
the dropdown along with the leftSidebarCompact state, the
ActivityDensity type, and the unused dropdown-menu / Settings imports.
The thread list keeps the compact layout (the previous default).
Co-authored-by: Orca <help@stably.ai>
* fix(activity): vertically center Jump to workspace button
The header row uses items-start so a 2-line prompt clamps without the
badge jumping. self-center on the button overrides that just for the
action so it sits in the middle of the title block instead of pinned to
the top edge.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): prefer prompt over OSC live title for thread label
paneTitleForEvent put the non-default tab.title ahead of the prompt, so
agent CLIs that set "Claude Code" / "Codex" via OSC pinned every row
and the right-pane heading to the agent name. The dashboard row this
function claims to mirror uses the prompt directly. Re-order to:
customTitle (explicit rename) → prompt → non-default liveTitle →
defaultTitle, so sending "hi" actually shows "hi" in the title.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): drop extra row height when prompt is one line
The thread row's right column stacked time over the count badge with a
gap, which forced the row to ~48px even when the left column's prompt
was a single line. Collapse the right column into one horizontal
cluster (count + time/bell) so single-line rows stay tight, while
two-line prompts still drive the row taller as before.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): add top/bottom padding to thread rows
Bump py-1.5 → py-2.5 so single-line rows breathe and don't sit flush
against the row dividers.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): even out optical row padding (top read heavier than bottom)
Symmetric py-2.5 looked top-heavy because the title's leading-snug adds
internal whitespace above the cap-height that the secondary row
doesn't have below it. Trim the top to pt-2 / pb-2.5 so the row reads
balanced.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* Improve mobile terminal streaming performance
Co-authored-by: Orca <help@stably.ai>
* Add mobile clear terminal action
Co-authored-by: Orca <help@stably.ai>
* Fix terminal connection test mock
Co-authored-by: Orca <help@stably.ai>
* WIP: mobile markdown tabs before rebase
Co-authored-by: Orca <help@stably.ai>
* Add mobile markdown editing
Co-authored-by: Orca <help@stably.ai>
* Harden mobile tab and markdown sync
Co-authored-by: Orca <help@stably.ai>
* Fix mobile terminal reconnect loading race
Co-authored-by: Orca <help@stably.ai>
* Polish mobile terminal keyboard behavior
Co-authored-by: Orca <help@stably.ai>
* Simplify mobile markdown editor chrome
Co-authored-by: Orca <help@stably.ai>
* Move mobile markdown actions to top
Co-authored-by: Orca <help@stably.ai>
* Use app modals for markdown discard
Co-authored-by: Orca <help@stably.ai>
* Dismiss keyboard before markdown confirmations
Co-authored-by: Orca <help@stably.ai>
* Add mobile file explorer
Co-authored-by: Orca <help@stably.ai>
* Fix mobile file explorer type narrowing
Co-authored-by: Orca <help@stably.ai>
* Fix mobile files navigation param
Co-authored-by: Orca <help@stably.ai>
* Show mobile files connection wait state
Co-authored-by: Orca <help@stably.ai>
* Preview text files on mobile
Co-authored-by: Orca <help@stably.ai>
* Simplify mobile file previews
Co-authored-by: Orca <help@stably.ai>
* Clarify unavailable mobile file types
Co-authored-by: Orca <help@stably.ai>
* Fix mobile subscription and preview review issues
Co-authored-by: Orca <help@stably.ai>
* Keep fallback terminals visible on mobile
Co-authored-by: Orca <help@stably.ai>
* Keep mobile terminal tap active
Co-authored-by: Orca <help@stably.ai>
* Preserve mobile terminal fallback order
Co-authored-by: Orca <help@stably.ai>
* Fix mobile session tab authority
Co-authored-by: Orca <help@stably.ai>
* Run mobile tests in mobile CI lane
Co-authored-by: Orca <help@stably.ai>
* Bump mobile app version to 0.0.7
Co-authored-by: Orca <help@stably.ai>
* Allow main window IPC wiring size
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(session): gate session-write subscriber on relevant field changes
The App-level Zustand subscriber that debounces buildWorkspaceSessionPayload
fires on every store update (agent status, usage refreshes, runtime title
ticks, …). Each fire reset the 150ms timer, and when the timer eventually
expired the rebuild crossed 70-110ms with many tabs open, tripping
setTimeout violation warnings. Add a shallow reference-equality gate over
the fields actually consumed by the payload so the timer only resets when
those fields change. The field list is co-located with
WorkspaceSessionSnapshot and locked to it via a compile-time exhaustiveness
check, so adding a future snapshot field will fail to typecheck rather
than silently skip the gate.
Co-authored-by: Orca <help@stably.ai>
* test(session): regression test for session-write debounce gate
Extract the subscriber into createSessionWriteSubscriber so a vitest can
drive the real Zustand store and assert which mutations cause a session
write. The gate against unrelated updates (agent status, cache timers,
runtime title ticks) is load-bearing for setTimeout violation budgets
and the failure mode is silent — without this test, future store
additions could re-introduce the regression unnoticed.
Six cases lock in the contract: no write while not ready, exactly one
write when ready flips, no write on unrelated mutations, exactly one
write on a relevant mutation, coalescing within a debounce window, and
cleanup cancels a pending timer.
Co-authored-by: Orca <help@stably.ai>
* perf(session): rebuild session payload from latest store state in debounce
Replace the closed-over `state` snapshot captured at timer-schedule time
with `store.getState()` inside the setTimeout callback. Today this is
behaviorally equivalent because `buildWorkspaceSessionPayload` reads only
SESSION_RELEVANT_FIELDS (the same fields gating the timer reset), but a
future refactor that adds a non-relevant field read to the payload builder
would silently start emitting stale values without this guard.
Also tighten the cleanup test: mutate the store after `cleanup()` and assert
no persist, so a regression where the timer is cancelled but the listener
is left subscribed would now fail rather than pass.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
After running `gh auth refresh` in a terminal, users had to manually
reload Orca to pick up the new token state. Surface a one-click Reload
button in both the block and banner variants of GhAuthErrorHelp.
Co-authored-by: Orca <help@stably.ai>
Aligned shouldUseMacOSNativeProvider gate with send-time check by using resolveMacOSComputerUseExecutablePath, restoring symmetry so RPCs no longer throw when only the bundle exists.
Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
* Fix terminal output lag from background panes
Co-authored-by: Orca <help@stably.ai>
* Fix scheduler edge cases: replay ordering, foreground gate, dispose race
- Drain queued background bytes before replay/snapshot writes so the
scheduler's deferred drain cannot land older bytes on top of the replay.
- Gate foreground on isVisibleRef only — visible-but-inactive split panes
should not be throttled; only hidden panes (background tabs) should be.
- Catch writes to disposed terminals in the drain loop so a late PTY ping
after pane.terminal.dispose() drops the dead entry instead of crashing
the scheduler for other panes still draining.
- Update App.tsx comment to drop stale agentStatusEpoch reference; epoch
no longer ticks on every PTY event after the agent-status slice change.
- Guard e2e Math.max(...drainWrites) against empty array to avoid a
vacuous pass.
Co-authored-by: Orca <help@stably.ai>
* Fix flaky e2e: put background marker after burst payload
The terminal-output-scheduler e2e test asserts that a background marker
appears in the terminal buffer after switching to that tab. getTerminalContent
returns only the last 4000 chars of the serialized buffer; with the marker
prefixed before a 50000-char x-burst, the marker is always evicted and the
final assertion always fails.
Move the marker to the END of the burst so it survives tail truncation. The
burst itself remains the same length, so the chunked-drain invariants the
test exercises are unchanged.
Co-authored-by: Orca <help@stably.ai>
* Add terminal scheduler regression coverage
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(ssh): stream fs.readFile to lift 10MB SSH preview cap (#1095)
Replaces the single-shot fs.readFile path on the SSH relay with a
push-style stream protocol modeled on VS Code's readFileStream.
Wire shape:
- fs.readFileStream request returns metadata (streamId, totalSize,
isBinary, mimeType, chunkEncoding, resultEncoding, optional empty)
- Relay pumps fs.streamChunk notifications (256 KB base64 chunks) and
ends with fs.streamEnd or fs.streamError
- Client cancels via fs.cancelStream notification
Invariants:
- Max 16 concurrent streams per FsHandler (TooManyStreams)
- Client clamps totalSize against caps before allocating
- Sequence-number defense against out-of-order/missing chunks
- Subscribe-before-await with frame queueing until streamId is known
- Pump cleans up registry+handle in finally; disposeAll aborts before
release so in-flight reads exit cleanly instead of EBADF
- Empty files short-circuit (no streamId, no handle open)
Compat:
- New client tries fs.readFileStream first, falls back to legacy
fs.readFile on JSON-RPC -32601 (with once-per-session warn log)
- Bumps MAX_PREVIEWABLE_BINARY_SIZE 10 MB to 50 MB to match local
Tests: 91 streaming tests across relay, client, mux, integration.
Co-authored-by: Orca <help@stably.ai>
* test(ssh): wait for streamEnd instead of fixed flush() in stream test
Why: the binary-streaming test relied on 5 setImmediate ticks to drain
the pump, which is racy on slower CI runners (each handle.read is async
I/O). Swap to a deadline-bounded waitFor(streamEnd) so the test is
deterministic regardless of scheduler latency.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): preserve small binary detection in streamed reads
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): rebind file watcher when connection id hydrates
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): refresh explorer for update-only file creates
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): recompute file watches when repo connection changes
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(ssh): refresh explorer for update-only file creates"
This reverts commit 7c3c683cd0929aa90723f29bddd741df311c1361.
* fix(ssh): install relay watcher dependency
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): enable TCP_NODELAY on ssh2 client to eliminate per-keystroke typing lag (#1660)
ssh2 leaves Nagle's algorithm on by default. For single-byte keystrokes
through a remote PTY, Nagle interacts with the kernel's delayed-ACK timer
and adds up to ~40 ms per keystroke — visible as the typing lag reported
in #1660. OpenSSH's `ssh` client sets TCP_NODELAY whenever a PTY is
allocated; this change mirrors that on the ssh2 client right after the
`ready` event in doSsh2Connect, covering both initial connect and
auto-reconnect.
Proxy-command / proxy-jump connections (where ssh2's underlying socket
is a custom Duplex over a child-process pipe) are a no-op by design,
gated by the public Client.setNoDelay()'s own type guard. A discriminating
log line records which path each connect took.
Tests cover initial connect and a full reconnect cycle to guard against
the regression class "Nagle is re-enabled because someone refactored
only the initial connect path."
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): bound relay-lost reconnect with exponential backoff
When the relay exec channel keeps dying (e.g. a remote-side bug closes
every fresh --connect channel right after handshake, or a stale bridge
keeps being replaced), the unguarded _onRelayLost handler reconnects as
fast as the network allows — spawning relay deploy attempts in a tight
loop until the user force-quits. Each iteration spawns a fresh ssh2 exec
channel, hammers sshd's MaxSessions counter, and floods the renderer
with state churn.
Add per-target exponential backoff (500ms → 15s, capped at 6 attempts)
so the loop terminates instead of running forever. After the cap the
session goes to 'error' state with a 'Relay channel kept dropping.
Please reconnect.' message — visible in the renderer instead of an
invisible failure where typing in remote terminals just stops working.
Successful 'ready' resets the attempt counter only if the session
stabilized for >= 5s; faster flaps preserve the counter so a flaky
remote backs off rather than retrying indefinitely on every brief
ready→lost cycle.
Backoff state is cleared on explicit disconnect, on session replacement
during reconnect, and on connect failures, so a real reconnect attempt
after backoff exhaustion always starts from zero.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): detect stale relay daemons via running-version marker
The on-disk relay version check compares local .version against the
remote .version file in the relay dir. A daemon launched by an earlier
deploy keeps running its in-memory copy of the OLD relay code, so when
the client later rewrites relay.js + .version on disk and bridges in
via --connect, the new bridge process drives a stale daemon. Protocol
or behavior changes between the two versions then tear down the
channel in a tight reconnect loop (observed against PR #1672 on a
daemon predating that change).
The daemon now writes its running version into a .running-version
sidecar at startup, anchored to the relay-script directory rather than
process.cwd() so test spawns cannot pollute the repo root. Before
attaching to an existing socket, the client probes that marker and,
on mismatch with the locally-deployed .version, kills the stale
daemon (TERM only, never KILL) and falls through to a fresh launch.
Conservative defaults: when either marker is unreadable, attach so
older builds keep their live PTYs.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(ssh): detect stale relay daemons via running-version marker"
This reverts commit e58acf07c04db5178bd9db62ff02b38aaee86db0.
* fix(ssh): isolate relay versions via per-version install dirs and wire handshake
The relay's previous single-dir layout (~/.orca-remote/relay-v0.1.0/) let
the deploy step rewrite relay.js in place while a daemon was still loaded
in memory at the previous version. New clients then drove that stale
daemon, surfacing as a reconnect loop (issue #1660 follow-up) and the
field failure observed against an 8-day-old daemon on openclaw.
Switch to a VS Code-style versioned layout where each (RELAY_VERSION +
content-hash) bundle installs into its own directory and is never
mutated after install. A v2 client's --connect socket path is rooted in
relay-${v2-hash}/ and structurally cannot reach a v1 daemon's socket.
Defense-in-depth: the daemon now reads exactly one Handshake-typed frame
on each newly-accepted Unix socket before attaching the JSON-RPC
dispatcher (mirrors VS Code's remoteExtensionHostAgentServer.ts:340).
Mismatch closes the socket; the bridge exits with code 42; client maps
that to a typed RelayVersionMismatchError and skips the relay-lost
backoff loop instead of retrying through 6 attempts.
Other deploy hardening:
- atomic mkdir-based install lock with stale-lock recovery serialises
concurrent first-installs of the same version
- .install-complete sentinel distinguishes a finished install from a
crashed-mid-install partial that should be retried
- gcOldRelayVersions removes unreferenced sibling dirs (allowlist regex,
skips locked or incomplete dirs, skips dirs with a live socket)
- readLocalFullVersion fails fast on a missing/empty local .version
rather than silently falling back to a path where a daemon from a
different code generation may already be running
Includes a cross-version isolation test that fails any future refactor
which collapses the per-version layout back to a shared dir.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): harden relay versioning per review feedback
Address must-fix and should-fix findings from the parallel triple review of
26d1666e:
- Surface RelayVersionMismatchError to ssh.ts on initial establish() (not
just reconnect), so the user sees the typed terminal error instead of
silent retry on first connect (#13).
- Give the sentinel timeout a 500ms grace window for the close handler to
deliver exit-42, so a slow remote does not misclassify a wire-handshake
mismatch as a generic timeout (#D11).
- Drain the handshake decoder's residue at the handshake -> dispatcher
transition on both daemon and --connect sides; pipelined frames that
were coalesced with the handshake are now forwarded into the dispatcher
/ stdout instead of silently dropped (#A1, #A2).
- Reset the install-lock acquire timer after a stale-lock recovery so a
single post-recovery race does not immediately exhaust the budget (#E14).
- Treat a stale install-lock as recoverable in the GC pass when
.install-complete is present (covers an interrupted finalize where the
rm-lock failed) (#E15).
- GC legacy relay-v\d+\.\d+\.\d+ install dirs whose daemons have died,
now that .install-complete is no longer required for them (#12).
- Resolve symlinks in readLaunchVersion() so a daemon launched via a
symlinked entry script still reads .version next to the real file (#G21).
- Flush stderr before exit-42 in --connect handshake mismatch path so the
diagnostic line reaches the client before the process tears down (#C8).
Tests:
- Round-trip handshake over a real Socket pair: matching version, mismatch
exit-42, leftover bytes preserved on both sides when frames are
coalesced with the handshake.
- waitForSentinel exit-42 -> RelayVersionMismatchError, exit-1 -> generic.
- SshRelaySession terminal-error callback fires on both establish() and
reconnect() when deployAndLaunchRelay throws RelayVersionMismatchError.
- acquireInstallLock concurrent BUSY -> OK polling, stale-lock recovery
with reset timeout window, and fresh-lock timeout failure path.
- gcOldRelayVersions stale-lock-with-complete branch, legacy-dead path,
legacy-alive path; existing locked-test asserts fresh-lock now keeps.
- Cross-version isolation test now asserts a blanket invariant that every
v1-referencing command from a v2 deploy is a read-only liveness probe.
Lint and typecheck clean across all 3 tsconfigs; 426 SSH/relay tests pass.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): bypass npm init for content-hashed relay dirs and harden install probe
The versioned-install dirs land at `relay-${version}+${hash}/` (e.g.
`relay-0.1.0+07994a7870e1`). npm 11 / Node 26 reject the `+` in derived
package names and `npm init -y` exits 1 — silently, since both stderr
and the failure landed inside the `2>/dev/null && ...` chain. The catch
swallowed the throw, `.install-complete` was written anyway, and every
reconnect surfaced 'node-pty is not available' at first pty.spawn.
Sidestep `npm init` entirely: SFTP-write a hardcoded minimal
package.json (`name: orca-relay`, `type: commonjs`) and run
`npm install node-pty` directly. `type: commonjs` pins the module
system against future Node default flips or remote-side .npmrc overrides.
Also harden the install path against the same class of silent failure:
- npm install errors now propagate (no more `.install-complete` on hard
fail; future reconnects retry instead of stranding the user)
- Replace the weak `test -d node-pty` post-install probe with
`node -e 'require("node-pty")'` so built-but-unloadable installs
(missing prebuild, wrong arch, broken native binding) surface clearly
- Add a session-level error handler on the SFTP write so a torn-down
session rejects the promise instead of hanging until enclosing timeout
Separate fix: add `for-each-ref` to the relay's git subcommand allowlist.
Client code (`src/main/git/repo.ts` ref-search and worktree-listing)
calls `git for-each-ref` over SSH; the relay was rejecting it. The
`--shell`/`--python`/`--perl`/`--tcl` format flags only control output
quoting (no eval) and the relay invokes git via execFileAsync (no shell),
so the read-only allowlist treatment matches `rev-parse`, `log`, etc.
Co-authored-by: Orca <help@stably.ai>
* comment(ssh-relay): TODO link to #1693 for VS Code-style pre-bundled node-pty
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): harden node-pty install probe and tighten review-fix tests
Round-3 review fixes on top of 963f56d7.
deploy.ts:
- Replace endsWith('OK') with includes('ORCA-NPTY-PROBE-OK'). Node can emit
deprecation/experimental warnings to stderr after our stdout 'OK' write,
and 2>&1 would push them past 'OK' producing false NPTY-MISSING warnings.
A unique sentinel survives any trailing stderr noise.
- Switch sftpPkg/ws .on -> .once for error/close. A late session 'error'
after the promise had already settled would otherwise become an unhandled
EventEmitter error and crash main.
- Trim per-block comments to 1-2 lines per AGENTS.md (was 7-9).
Tests:
- Pin the BEFORE-ordering contract: SftpWriteCapture now records the count
of execCommand calls observed at the moment ws.end() ran for each path,
and the test asserts that count <= the index of npm install. Catches a
future Promise.all-style refactor that would still pass final-state checks.
- Strengthen the SSH-channel-failure test: assert the rejection actually
came from the probe call (not an earlier exec) by finding the probe
invocation in mock.calls. Also assert NPTY-INSTALL-FAIL is NOT logged
(channel failure must not be conflated with install failure) and that
abandonInstall was called so the lock is released.
- Fix misleading clearAllMocks comment: it claims to wipe mockReturnValue,
but actually clearAllMocks only resets .mock.calls. Re-priming was
defense-in-depth, not a correctness requirement.
Validator:
- Add for-each-ref negative cases (--git-dir, --output, --work-tree) to the
global-denied-flags it.each. The first round of for-each-ref enablement
trusted that the post-subcommand GLOBAL_DENIED_FLAGS check applied; this
pins it so a future allowlist refactor that bypasses the global check
fails loudly.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): split node-pty probe into test-d guard + load-test
Round-4 review fixes for the install probe in installNativeDeps:
(1) test -d guard runs before the load-test. If the install dir vanished
between npm install and probe (concurrent rm, fs unmount, permission
flip), the deploy now throws and the next reconnect retries fresh —
previously the cd failure flowed into '|| echo MISSING' and we'd
write .install-complete, stranding the user in degraded mode.
(2) Load-test discards stderr (2>/dev/null) so customized .bashrc
output (NVM init, conda greetings, etc.) can't pollute the sentinel
match. The shell-level '|| echo MISSING' is preserved so SSH-channel
rejections still propagate as exec errors, distinct from require
failures which exit the node process nonzero.
(3) PROBE_OK is passed via process.argv[1] so the JS literal stays
trivial regardless of future sentinel characters.
Test changes:
- New 'dir-gone' probe mode in makeExecResponses
- New test pinning that vanished-dir throws (not silent MISSING)
- SSH-channel test now asserts probeCallIdx > npmInstallIdx
- cross-version-isolation feeds an extra '' for the test -d slot
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): simplify node-pty probe and harden test ordering pins
Round-5 review fixes for installNativeDeps:
Production:
- Drop redundant test -d guard. `cd ${dir} && (...)` short-circuits on
cd-failure (dir-vanished) and propagates as exec reject already; the
separate guard added a round trip without preventing anything.
- Capture probe stderr to a per-deploy file rather than 2>&1 or 2>/dev/null.
.bashrc noise can't pollute the sentinel match, but the require() error
message is preserved in the [NPTY-MISSING] log breadcrumb so bug reports
point at the real cause (e.g. GLIBC version mismatch).
- Mirror the install command's PATH (export PATH=${binDir}:$PATH) so any
future require-time child_process call resolves the same node binary
used during install.
- Add platform tuple to [NPTY-MISSING] and [NPTY-INSTALL-FAIL] logs for
triageable bug reports without asking users to dig out their arch.
- Trim probe comment per AGENTS.md (why-only, no mechanism narration).
Tests:
- Pin full installNativeDeps ordering: npm install < chmod prebuilds <
probe. Catches refactors that probe before install or move chmod after.
- Pressure-test .includes(PROBE_OK) survives bashrc/MOTD noise prefixed
to probe stdout (corporate banner / NVM init / conda greeting case).
- Pressure-test MISSING detection survives Node deprecation warnings
prepended to the MISSING token.
- Pin platform tuple appears in [NPTY-MISSING] log.
- Pin finalizeInstall called exactly once + abandonInstall not called
on happy paths; reverse on failure paths.
- Strengthen dir-gone test: assert probeIdx > npmInstallIdx so a refactor
that swaps order doesn't silently let the test pass on its own injected
error string.
- New probeStdoutOverride option in makeExecResponses for shell-noise
injection tests.
Cross-version-isolation: dropped obsolete test -d slot, added rm-stderr
cleanup slot to match the new probe shape.
eslint-disable max-lines on both files with rationale (pattern used widely
in this repo for cohesive single-responsibility modules).
441/441 tests pass; lint clean; typecheck clean. Probe shape verified
end-to-end on real remote.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix(worktree): preserve user push.autoSetupRemote, include path in warn
- Probe push.autoSetupRemote with `git config --get` before writing so a
deliberate user value at any scope (local/global/system) is preserved.
- Include worktree path in the warn log for failed config writes.
- Add test pinning the preserve-existing-value behavior.
- Remove stray 00-review-context.md committed during review tooling.
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix(worktree): narrow config --get error handling, tighten test asserts
Treat only exit code 1 from `git config --get push.autoSetupRemote`
as "key unset". Other read failures (corrupt config, locked file,
parse error) now re-throw to the outer warn handler instead of being
silently treated as unset and overwriting whatever value the user
actually has.
Also: add test for the non-unset read-error path; convert the
"preserves existing value" test from `.some()` predicates to a
full-array `toEqual` matching sibling-test style; explicitly mock
`config --get` (with code: 1) in the sparse-failure rollback test
so it exercises the intended branch instead of the helper's empty-
stdout fallthrough; document in the design notes that
addSparseWorktree's rollback intentionally does not unset
push.autoSetupRemote.
Co-authored-by: Orca <help@stably.ai>
* test(worktree): pin --get-empty-stdout and worktree-add-fail invariants
Why: addWorktree's post-create config probe has two ordering
invariants worth pinning so a future refactor can't silently
regress them: (1) `git config --get` succeeding with empty stdout
still counts as "already set" so we don't overwrite an explicit
empty value, and (2) the entire config block is skipped when
`worktree add` itself rejects.
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* docs(worktree): cross-ref local↔SSH addWorktree, clarify SSH-host git version, add empty-stdout parity test
JSDoc on local addWorktree now flags the push.autoSetupRemote side
effect; both paths cross-reference each other so the next change keeps
them in lockstep. Relay comment clarifies that the git version that
matters is the SSH host's, not the client's. Adds the missing
empty-stdout-as-already-set parity test on the relay side.
Co-authored-by: Orca <help@stably.ai>
* chore: remove 00-review-context.md from PR
Stray file from local review workflow; should not ship in this PR.
Co-authored-by: Orca <help@stably.ai>
* chore: remove worktree-ssh-no-track-parity.md from PR
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(agent-dashboard): persist hook status across Orca restart
Hydrates the hook server's per-pane lastStatusByPaneKey from
userData/agent-hooks/last-status.json before binding the HTTP listener,
mirrors mutations to disk via a 250ms trailing debounce, and flushes
synchronously on stop(). Renderer dismissals fan out a new
agentStatus:drop IPC so the on-disk file evicts the entry and a
relaunch cannot resurrect it. Adds a bounded bootstrap queue in
useIpcEvents so events replayed by setListener() during window creation
are not dropped while App.tsx is still hydrating tabsByWorktree.
Gated on settings.experimentalAgentDashboard. Done, blocked, and quiet
working rows now all survive across restart.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-dashboard): harden hook persistence IPC and gate-off deletion
Address review findings on the retention-restart branch:
- Wrap agentStatus:getSnapshot and agentStatus:drop IPC handlers in
try/catch so a throw cannot surface as an unhandled invoke rejection
(silent startup-hydration failure) or crash main from a fire-and-
forget listener.
- runStatusPersist no longer permanently suppresses gate-off deletion
retries on transient unlink errors (e.g. EPERM); deletedOnDisable
now flips only on success or ENOENT.
- Tighten tests: stale-version-hydrate now asserts the warn message
content; getSnapshot test uses toEqual; drop-handler test rejects
null/{}/[] in addition to the prior bad inputs.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-dashboard): bound on-disk hydrate growth and reject tabId/paneKey drift
- Drop hydrate entries older than 7 days (HYDRATE_MAX_AGE_MS) so stale
rows from worktrees archived weeks ago do not pile up forever. PTY-
teardown eviction handles closed panes; the TTL covers daemon-restored
PTYs that never re-attach and crash-recovery paths.
- Reject hydrate entries whose `tabId` field diverges from the paneKey's
tab segment. Cheap defensive add against future renamer/shape drift.
Doc updated to move TTL out of the follow-ups list (now in scope).
Tests: new "drops hydrate entries older than the TTL cutoff" and "drops
a hydrate entry whose tabId disagrees with the paneKey prefix"; existing
hydrate fixtures now use a `recentTs()` helper instead of fixed 2023
timestamps.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-dashboard): post-review polish on hook status persistence
Apply review-fix corrections on the agent-dashboard restart-persistence
work:
- Split dropStatusEntry from clearPaneState so renderer-driven dismiss
IPC no longer wipes lastPromptByPaneKey/lastToolByPaneKey for a
still-alive pane.
- Validate paneKey shape at the IPC boundary (isValidPaneKey).
- Let getSnapshot errors propagate instead of silently returning [] —
matches the renderer's existing .catch and avoids masking a broken
persistence path.
- Trust main's authoritative timing.stateStartedAt unconditionally on
same-state pings; fall back to existing only when timing is absent.
- Use strict < on the snapshot/live updatedAt guard so two events in
the same millisecond don't drop the second one (a <= guard regressed
two existing slice tests).
- Don't reset snapshotRequestedForReadyWindow in the catch handler;
combined with the per-store-update subscriber it would retry-storm
on persistent IPC failure.
- scheduleStatusPersist now resets the timer on each call (true
trailing-edge debounce) instead of leading-edge throttle.
- Fix doc references that named clearPaneState in dismiss/IPC context
where the implementation uses dropStatusEntry; add type-level JSDoc
on AgentStatusIpcPayload.
109/109 in-scope tests pass.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-dashboard): clean stale on-disk entries during hydrate
- Defensive `lastStatusByPaneKey.clear()` at top of `hydrateLastStatusFromDisk` keeps repeat-start() calls from silently merging prior-session state.
- When sanitize drops entries (drift, TTL, schema), log a single `[agent-hooks] last-status hydrate dropped N entries (kept M)` warn and synchronously rewrite the file. Pre-fix, stale entries stayed on disk until a fresh hook event triggered a debounced write — users who hadn't run an agent in 8+ days would re-drop the same entries every cold boot.
- Prime `lastWrittenJson` from the raw on-disk bytes (instead of re-serializing) when hydration is lossless — robust against future shape drift in `serializeStatusFile`.
- `LAST_STATUS_FILE_VERSION = 2` comment now records why v1 was skipped (in-flight branch shape).
- IPC test mock uses `vi.importActual` for `isValidPaneKey` so it stays in sync with the real validator.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-dashboard): persist acknowledgedAgentsByPaneKey across restart
Without this, agent rows the user already visited come back bold every relaunch now that the rows themselves survive restart (per docs/agent-dashboard-retention-restart.md). Hydrate sanitizes input field-by-field (rejects null/non-object/array, prototype-pollution keys, non-finite/non-positive values) and applies a 7-day TTL paralleling HYDRATE_MAX_AGE_MS in agent-hooks/server.ts so hard-quit/crash paths can't grow the persisted map forever.
Co-authored-by: Orca <help@stably.ai>
* docs(agent-dashboard): drop in-tree retention/restart design doc
Doc was a working artifact for this branch; the rationale lives in commit
history and the comments next to the persistence/hydrate code. Scrubs the
three call-site references that named it.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
H2 (and exported H1) headings rendered with a thin border below them;
this removes the rule so headings sit flush with following content.
Co-authored-by: Orca <help@stably.ai>
When the editor is disposed during a parent render, the dispose
listener's setState re-runs this effect and triggers a synchronous
root.unmount() inside React's commit work loop, producing React 19's
"Attempted to synchronously unmount a root while React was already
rendering" warning. Snapshot the roots and clear bookkeeping
synchronously, then unmount via queueMicrotask — matches the
deferred-unmount pattern already used in the diff-pass effect.
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix(updater): repair retry-state correctness in release-transition fallback
Address issues surfaced by automated multi-agent review on the 30s
silent-retry + 1h backstop introduced in this branch:
- forceLaunchUpdateCheck now OR-merges userInitiatedCheck instead of
overwriting it, so a manual click during the 30s wait survives the
timer's launch (the click's upgrade was being clobbered).
- The .catch path mirrors the same OR-merge by reading the live module
flag, so a synchronous throw or pinPrereleaseFeed rejection during the
retry doesn't lose the click upgrade either.
- checkForUpdatesFromMenu upgrades userInitiatedCheck = true before
early-returning during the 30s wait, so the in-flight retry's result
reflects the user's click.
- Removed cross-cancellation between the 30s retry and 1h backstop
callbacks: each callback only nulls its own handle, and both timers
stay armed until a terminal event clears them centrally. The backstop
is no longer destroyed at T+30s, restoring the app-nap recovery the
design intended.
- 'error' handler's non-'checking' branch now (a) clears retry state
unconditionally so transitionRetryInFlight can never be stranded
across a status-race, and (b) suppresses sendErrorStatus when status
has already advanced to a good terminal (available/downloading/
downloaded), preventing a late backstop error from overwriting a
successful retry result.
- performQuitAndInstall now clears the retry timers and flag so the
install-quit path (which bypasses the before-quit handler via
markMacQuitAndInstallInFlight) doesn't leak a timer firing into the
bundle-replacement window.
Co-authored-by: Orca <help@stably.ai>
* fix(updater): simplify benign check failures
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Force a freshness check each time the user enters the Checks tab
(open sidebar, switch to Checks tab, or switch active worktree/branch)
so stale PR metadata, cached-null "no PR" results, stale checks, and
stale comments are surfaced immediately rather than waiting for the
cache TTL.
- Extracts entry-refresh logic into `checks-entry-refresh.ts` with a
30 s grace window to suppress rapid show/hide duplicate fetches.
- Adds a `shouldEntryRefresh` effect in `ChecksPanel` keyed by
`activeWorktreeId::repo.path::branch`; resets on panel hide so
close-and-reopen re-evaluates freshness.
- Fixes a stale-closure bug in `handleRefresh`: `fetchPRChecks` is now
called directly with the freshly resolved `headSha` after PR refresh
instead of reusing the pre-refresh closure's captured sha.
- Adds 11 unit tests in `checks-entry-refresh.test.ts`.
- Design doc: `docs/refresh-on-checks-tab.md`.
Co-authored-by: Orca <help@stably.ai>
* feat(sidebar): allow manual drag-and-drop reordering of repos
Users can now drag repo headers in the sidebar to reorder them. The
custom order is persisted to disk and survives restarts. Includes
design doc at docs/manual-repo-reorder.md.
Co-authored-by: Orca <help@stably.ai>
* fix: scope post-drag click swallow to dragged repo header
Avoid silently eating unrelated clicks if one races between pointerup and
the failsafe teardown.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Updates the Custom Sound search entry description and keywords to include
the supported formats (MP3, WAV, OGG, M4A, AAC, FLAC), and adds a small
caption under the setting's description in NotificationsPane for clarity.
Co-authored-by: Orca <help@stably.ai>
* feat(agent-hooks): introduce relay wire envelope + connectionId stamping
Adds the shared `agent-hook-relay.ts` module with the `agent.hook` JSON-RPC
notification envelope, the `agent_hook.requestReplay` /
`agent_hook.installPlugins` method names, and the
`ORCA_FEATURE_REMOTE_AGENT_HOOKS` flag helper. Promotes `AgentHookSource` to
`shared/` so the relay can import it without dragging Electron in.
Threads a `connectionId: string | null` field through `AgentHookEventPayload`,
the `agentStatus:set` IPC contract, and the renderer-bound preload listener.
Local hook posts stamp `null`; the relay-forwarded path will stamp from `mux`
identity in a later commit. Renderer uses the stamp for stale-event filtering
when an SSH connection tears down with notifications still in flight.
See docs/design/agent-status-over-ssh.md §1, §5, §8 (commit #1).
Co-authored-by: Orca <help@stably.ai>
* refactor(agent-hooks): extract shared listener; add relay-side adapter
Extracts the listener internals (request parsing, payload normalization,
endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer
helper, request size cap, paneKey caches) from `src/main/agent-hooks/server.ts`
into a new transport-agnostic `src/shared/agent-hook-listener.ts`. The shared
module uses only Node builtins (no Electron) so it is safe to import from
`src/relay/`.
Adds `src/relay/agent-hook-server.ts` — a thin HTTP-loopback adapter that
wires the shared listener to a `forward(envelope)` callback so `relay.ts` can
re-emit each parsed payload as an `agent.hook` JSON-RPC notification on the
existing SshChannelMultiplexer. The adapter owns:
- 127.0.0.1:0 socket + bearer-token auth, identical shape to the local server
- per-paneKey last-payload cache + replayCachedPayloadsForPanes() for the
request-driven replay path used after `--connect` reattach (see §5 Path 3)
- clearPaneState(paneKey) for PTY-exit eviction (symmetric with local server)
- buildPtyEnv() / endpoint-file writing for relay-spawned PTYs
Orca's `AgentHookServer` is now a ~200-LoC adapter over the shared listener
that owns the IPC fanout, listener replay, and `ingestRemote(envelope, connId)`
entry point that bypasses the HTTP path for relay-forwarded events.
See docs/design/agent-status-over-ssh.md §3, §8 (commit #2).
Co-authored-by: Orca <help@stably.ai>
* fix(preload): expose connectionId on agentStatus.onSet type
src/preload/index.ts already passes through `connectionId?: string | null`
from main, but the PreloadApi declaration in api-types.ts was missing the
field. Align the type with the runtime contract so renderer call sites
can read connectionId without an `as` cast.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-hooks): harden ingestRemote + relay replay; review-driven cleanup
- ingestRemote: re-run normalizeAgentStatusPayload at trust boundary;
trim+validate connectionId/paneKey/tabId/worktreeId
- relay: preserve source/env/version through replay via sidecar map;
drop sourceFromAgentType fallback that mis-tagged unknown agents
- shared listener: exhaustive switch+never on AgentHookSource dispatch
chains; extractPromptText returns trimmed values; export MAX_PANE_KEY_LEN
- preload: tighten connectionId from optional to required (always sent)
- main IPC: reorder spread so explicit envelope fields win on collision
Co-authored-by: Orca <help@stably.ai>
* chore(docs): drop agent-status-over-ssh design doc from PR
The design RFC was useful for authoring this PR series but doesn't belong
in-tree — keeping it here would freeze line-number references and design
prose against future churn. Folding it into the PR description instead.
Co-authored-by: Orca <help@stably.ai>
* chore(agent-hooks): widen ingestRemote type for env/version (PR2 prep)
Declares `env?: string` and `version?: string` on the `ingestRemote` envelope
parameter so PR2 only needs to add the `warnOnHookEnvOrVersionMismatch`
callsite, not also widen the type. The fields are forwarded verbatim from
the agent CLI POST body on the remote and let Orca's warn-once cross-build
/ dev-vs-prod diagnostics fire identically on remote-sourced events.
Type-only addition; no runtime consumer in this PR.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Replaces the DropdownMenu-based repo filter with a Command/Popover combo that
supports live search, All/None bulk actions, and a Clear all footer. Scales
to large repo sets without scroll friction. Design doc added at
docs/sidebar-filter-redesign.md.
Co-authored-by: Orca <help@stably.ai>
* fix: address pr-bug-scan validated finding from #1680
On cold open, optimistic comments are now surfaced via a loading-shell fallback in the details memo, with a state tick so the memo re-runs after appendOptimisticComment.
* fix: address react-hooks lint warnings on #1680 fix-PR
- handleSubmit useCallback: add missing itemType dep
- details useMemo: keep optimisticTick (rerender signal for cold-open
ref reads) with eslint-disable + why-comment
---------
Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
Co-authored-by: nwparker <neil@stably.ai>
Add legacy version-first ID branches (3-5-sonnet, 3-5-haiku) in normalizeModelForPricing so legacy logs map to existing pricing entries instead of returning null.
Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
Replace the setState-driven data flow with useSyncExternalStore so the
drawer reads cached work-item details synchronously on first render.
Warm reopens now paint the cached content immediately with zero blank
flash. Adds a pub/sub layer (subscribeWorkItemDetailsCache /
notifyWorkItemDetailsCache) to all cache-write paths so React is
notified on every touch or invalidation. Includes design doc at
docs/gh-work-item-drawer-cache-flash.md.
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix: address auto-review-fix-multi-agent findings
- Replace local ORCA_WORKTREE_ID_SEPARATOR with shared WORKTREE_ID_SEPARATOR
- Make hydrateLocalPtyRegistryAtBoot idempotent (one-shot per process,
but stays retry-eligible until daemon provider is available)
- Strengthen daemon-pty-adapter strict-parser test to actually exercise
the new short-circuit (test would have passed under the old loose
parser too without the change)
- Add eslint-disable max-lines directive to oversized merge test file
Co-authored-by: Orca <help@stably.ai>
* chore: archive auto-review context to .context/
Co-authored-by: Orca <help@stably.ai>
* fix: address auto-review-fix findings
Drop the destructive reconcileOnStartup call from boot-time PTY registry
hydration: a transient listRepoWorktrees failure (returns [] and only
warns) would otherwise let the reconcile pass kill live local sessions.
The boot path is now read-only against the daemon — listSessions() only.
Also: tighten parsePtySessionId to reject degenerate `::` halves; replace
stale pty.ts:1005 references and a misleading local-unknown comment in
the hydrate module; narrow Store dependency to Pick<Store, 'getRepos'>;
log adapter listSessions failures instead of silently swallowing them;
re-anchor design-doc references on stable symbols and align §1b/§1c/§1d
with the implementation.
Co-authored-by: Orca <help@stably.ai>
* docs(resource-usage): update remote badge spec
Co-authored-by: Orca <help@stably.ai>
* test(resource-usage): cover boot hydration failure modes + warm-reattach e2e
Adds the regression coverage flagged in PR #1667's test plan that wasn't
already locked down.
vitest (`hydrate-local-pty-registry.test.ts`):
- daemon offline at first call → no-op, hasHydrated stays false so a
later macOS dock re-activation can retry.
- listSessions rejection caught and logged, does not throw.
- pid-write ordering: a pre-existing registry entry with pid=12345 is
not clobbered by a stale `pid: null` from listSessions (§1d).
- SSH-gate: a session whose repo has a non-null connectionId stays out
of the registry, mirroring the spawn-time gate in pty.ts.
- Happy-path: a local session is registered with the daemon's pid.
Playwright e2e (`resource-usage-warm-reattach.spec.ts`):
Full quit→relaunch cycle against the same userDataDir; asserts that
on the second launch the snapshot includes the warm-reattached PTY
with a real pid before any pane mount, and that the seeded repo
resolves as local (no connectionId). Mirrors the existing
terminal-restart-persistence pattern.
Co-authored-by: Orca <help@stably.ai>
* fix(test): satisfy Pick<Store, 'getRepos'> in hydrator vitest
CI typecheck failed because FakeStore's getRepos returned objects missing
Repo's required fields (path, displayName, badgeColor, addedAt). Fill with
placeholder values; the hydrator only reads id + connectionId, but the
type signature still has to line up.
Co-authored-by: Orca <help@stably.ai>
* chore(resource-usage): drop bug-doc files; strip dead doc refs from comments
Remove docs/resource-usage-remote-mislabel.md (new in this PR) and revert
docs/resource-usage-merge-spec.md to the PR-base state. Strip the
matching `docs/...md §N` pointers from code/test comments, keeping the
surrounding "why" explanations intact so readers still get the
warm-reattach mislabel context.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix: gate worktree status on live PTYs so sleep reports inactive
Sleep preserves tab.ptyId as a wake-hint sessionId, so the previous
liveness check (`tab.ptyId != null`) kept the workspace dot green and
agent rows as "working" until the 30-min stale TTL decayed them.
Switch liveness to ptyIdsByTabId (cleared by every pty.kill / sleep)
via a new tabHasLivePty helper, and drop live agentStatusByPaneKey
entries on sleep so the inline rows disappear with the dot. Retained
"done" rows survive — that signal is dismissed by the user, not the
system.
Co-authored-by: Orca <help@stably.ai>
* fix: drop retained agent rows on worktree sleep
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix: preserve slept worktree status liveness
Co-authored-by: Orca <help@stably.ai>
* fix: treat slept pty hints as inactive
Co-authored-by: Orca <help@stably.ai>
* chore: remove sleep status planning docs
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
When a remote SSH workspace contains a symlink whose target lies outside
the registered repo/worktree roots, file reads failed with 'Path outside
authorized workspace'. This silently broke common workflows: HPC dataset
mounts, multi-checkout repos, dotfile editing, and any cross-mount
symlink.
Drop `RelayContext.authorizedRoots`, `validatePath`, and
`validatePathResolved` along with all ~33 call sites in fs-handler.ts
and git-handler.ts. The relay's threat model becomes 'the relay runs as
the SSH user and trusts the renderer.'
Why this is acceptable: `pty.spawn` and `git.exec` already concede the
same threat. A renderer that wants to reach `/etc/passwd` can spawn a
shell or run `git -C /etc cat-file`; the FS allowlist was friction, not
a security boundary. Intra-worktree path checks in `getDiff` and
`discard` are intentionally preserved.
Back-compat preserved: `session.registerRoot` (notification + request)
remains a valid RPC, retained as no-ops on new relays. Old main + new
relay and new main + old relay both keep working through the upgrade
window. `registerRelayRoots` is also kept for the same reason. A
narrowed error-translation block in `worktree-remote.ts` handles old
relays still surfacing the legacy error string to users.
Tests: removed two negative-allowlist tests; added a positive control
('reads files outside any registered root') and a direct regression
test for #1661 ('reads files via symlinks resolving outside the
workspace'). All 469 relay/SSH/IPC tests pass.
See docs/relay-fs-allowlist-removal.md for the full rationale,
back-compat matrix, alternatives considered, and follow-up cleanup
plan.
Closes#1661
Co-authored-by: Orca <help@stably.ai>
* feat(telemetry): instrument on_path:false triage on onboarding_agent_picked
Adds path_source and path_failure_reason to onboarding_agent_picked so the
~30% on_path:false rate on dashboard 1562016 can be split between shell
hydration failures and genuinely-not-on-PATH cases before picking a fix.
See docs/agent-on-path-detection.md.
Co-authored-by: Orca <help@stably.ai>
* fix(telemetry): close PathSource compile-time-sync hole
Add `_PathSourceSync` guard mirroring `_PathFailureReasonSync` so adding
a new `PathSource` value to the alias without updating the schema (or
vice versa) fails the build. Without it, drift would silently drop
`onboarding_agent_picked` at the strict validator. Also replace stale
line-number references in docs/agent-on-path-detection.md with named
function/handler references that survive future edits.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>