Merge origin/main into brennanb2025/agent-status-preserve-restart

Resolves the agent-hooks refactor collision from #1678 (shared listener +
relay adapter). The refactor extracted listener internals into
`src/shared/agent-hook-listener.ts` and replaced server.ts's module-level
`lastStatusByPaneKey` Map with `state.lastStatusByPaneKey` on a shared
`HookListenerState`. This branch's persistence layer is re-anchored on the
new shape:

- `AgentStatusIpcPayload` now carries `connectionId: string | null` (from
  main) alongside `receivedAt` / `stateStartedAt` (from this branch).
- `src/main/agent-hooks/server.ts` rewritten as the slim adapter
  (~720 LoC) over the shared listener: defines a server-process-only
  `EnrichedAgentHookEventPayload = AgentHookEventPayload & {receivedAt,
  stateStartedAt}` stored in `state.lastStatusByPaneKey` (the shared module
  never reads this map, so the extra fields ride along untouched), keeps
  `last-status.json` v2 hydrate / sanitize / TTL / atomic-write / drop
  semantics. The new HTTP and `ingestRemote` paths both run through
  `attachStatusTiming` before caching.
- `src/main/index.ts` IPC fanout forwards the union: connectionId +
  receivedAt + stateStartedAt + ...payload.
- `src/preload/{api-types,index}.ts` keep the typed `AgentStatusIpcPayload`
  surface (which now subsumes both branches' fields).
- `src/main/agent-hooks/server.test.ts`: persistence tests preserved as-is;
  ingestRemote tests from main re-laxed from `toHaveBeenCalledWith({...})` to
  `expect.objectContaining({...})` so the listener's enriched payload doesn't
  fail strict equality.

Verified: pnpm tc:node + tc:web clean; 776/776 vitest tests pass across
agent-hooks, shared listener, relay, IPC handler, and renderer agent-status
slice + ui slice. tc:cli has pre-existing TS6307 errors on origin/main that
are not introduced by this merge.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-11 00:40:49 -07:00
co-authored by Orca
43 changed files with 6489 additions and 1845 deletions
+2 -3
View File
@@ -67,9 +67,8 @@ design-docs/
.atl/
# Local-only design/planning docs (not checked in)
docs/*-design.md
docs/*-plan.md
docs/design-*.md
docs/*.md
!docs/README*.md
# Stably CLI (only docs/ are tracked)
.stably/*
+132
View File
@@ -0,0 +1,132 @@
# GitHub Work Item Drawer: Fix Reopen Flash
## Symptom
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
## Verified root cause
`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`).
Current sequence on close/reopen of the same item:
1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`.
2. Reopen renders once with `details === null`.
3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`.
4. Second render shows cached content.
With sheet animation, that first empty paint is visible as a flash.
## Refactor direction
Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render.
This is not optional if we want reopen to paint cached content on first render.
## Required cache-store changes
Add a store subscription layer around `workItemDetailsCache`:
- `subscribeWorkItemDetailsCache(listener)`
- `notifyWorkItemDetailsCache()`
Call `notifyWorkItemDetailsCache()` after every actual cache mutation:
- `touchWorkItemDetailsCache`
- `invalidateWorkItemDetailsCacheForKey`
- `invalidateWorkItemDetailsCacheByMatch`
Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
## `useSyncExternalStore` contract details
Use:
```ts
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
)
```
Important constraints:
- `getSnapshot` must return a referentially stable value when store data has not changed.
- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object).
- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops).
## Component state changes
Remove component-local `details/loading/error` state for work-item details.
Derive view state from `cachedEntry`:
- `details = cachedEntry?.details ?? null`
- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details`
- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null`
Keep stale-on-error behavior by preserving `details` in cache while setting `error`.
## Effect responsibilities after refactor
The main effect should:
- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`)
- decide freshness and in-flight dedupe
- launch fetch and write results/errors/pending back into cache
The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details.
## Optimistic comment race and merge rules
`appendOptimisticComment` must write through cache only (no local `setDetails`).
Rules:
- push comment into `optimisticCommentsRef`
- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify
- derive rendered comments from cached details plus optimistic refs
Dependency pitfall:
- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo`
- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
## Invalidation while drawer is open
When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted:
- subscription notifies immediately
- render sees `cachedEntry === undefined`
- UI falls back to loading/empty state and effect triggers refetch
This is expected and preferred over showing stale state.
## Fetch race constraints (must keep)
Keep current protections:
- per-key in-flight dedupe via `entry.pending`
- generation guard to block stale writes after invalidation
- stale-on-null handling (`result === null` must not wipe existing valid cached details)
Add one missing consistency rule:
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
## Scope of deletion
Delete only the work-item-detail local state and call sites:
- `useState<GitHubWorkItemDetails | null>` (`details`)
- `useState<boolean>` (`loading`) for this data path
- `useState<string | null>` (`error`) for this data path
- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path
Do not touch unrelated loading/error state in other subviews.
## Expected result
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.
+165
View File
@@ -0,0 +1,165 @@
# Auto-refresh on entering the Checks tab
## Problem
The Checks panel fetches on visibility, but those fetches are cache-respecting:
- `CACHE_TTL = 300_000` for PRs and comments.
- `CHECKS_CACHE_TTL = 60_000` for checks.
- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`.
- Checks polling and the comments effect also call the store without `force`.
That means entering the Checks tab can render stale PR metadata, a cached `null`
"no PR" result, stale comments, or stale checks. The checks path is partially
protected by a shorter renderer TTL, but the main process also uses `gh api
--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh
already bypasses these renderer and `gh api` caches for the requests it starts.
## Goal
When the user enters the Checks tab, run one freshness check for the active
worktree and force-refresh only when the relevant cache timestamps are older
than a small grace window. "Entering" means:
- opening the right sidebar while the Checks tab is selected;
- switching from another right-sidebar tab to Checks;
- switching active worktree/repo/branch while Checks is already visible.
The refresh must cover PR discovery too, including cached `null`, so a PR opened
outside Orca can appear without waiting up to 5 minutes.
## Non-goals
- Changing the existing polling cadence while the panel stays visible.
- Refreshing on window focus, network reconnect, or arbitrary background events.
- Changing the manual refresh button.
- Prefetching checks for non-active worktrees.
- Adding cross-renderer or cross-window request coordination. Orca currently has
one live main window; renderer in-flight maps are not a multi-window primitive.
## Design
1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by:
```ts
const entryKey = isPanelVisible && repo && !isFolder && branch
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
: ''
```
Track the last processed visible `entryKey` in a ref. When `entryKey` is empty,
reset the ref to `''`. When it becomes non-empty and differs from the ref, this
is an entry. This is required; a `prevKey !== currentKey` check that does not
reset on hide would miss closing and reopening the same PR.
2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in
`ChecksPanel.tsx`. Select only timestamps, not whole cache records:
```ts
const prFetchedAt = useAppStore(
(s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined)
)
const checksFetchedAt = useAppStore(
(s) => prNumber
? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt
: undefined
)
const commentsFetchedAt = useAppStore(
(s) => prNumber
? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt
: undefined
)
```
Missing PR cache is stale. A cached PR value of `null` is still a PR cache
entry and should be refreshed on entry once outside the grace window. When a
PR number is known, missing checks/comments timestamps are stale. When no PR is
known, checks/comments are not relevant yet.
Run `handleRefresh()` if the oldest relevant timestamp is missing or older
than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip.
3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The
manual refresh flow is the right shape: force `fetchPRForBranch`, then if a
PR is returned, force checks and comments for the returned PR. Extend
`fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks`
directly from `handleRefresh`) so the checks request uses
`refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR
refresh completed. This handles PR number changes, cached `null`, and
external force-pushes correctly.
4. **Reset polling attention state before refresh.** When the entry refresh runs,
set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before
calling `handleRefresh()`. `fetchChecks()` will then write the new signature
from the forced result.
5. **Do not overstate in-flight behavior.** Current store behavior is:
- `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR
request and uses a generation guard so the older result cannot overwrite the
newer cache entry.
- `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do
**not** bypass any in-flight request for the same key. If a non-forced poll
is already in flight, entry refresh will join it and may not pass
`noCache: true` to the main process.
Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a
visible polling race. If strict "entry always bypasses gh cache" semantics are
required later, change checks/comments in-flight maps to track `{ promise,
force, generation }` like PRs.
## API cost and feasibility
This is not free. A cold entry refresh can start:
- one PR lookup (`gh:prForBranch`);
- one checks request (`gh:prChecks`);
- one comments request (`gh:prComments`).
`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs`
and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr
checks`, which does not use the `--cache` flag.
`gh:prComments` is heavier than "one GitHub API call": it runs issue comments
REST, review threads GraphQL, and reviews REST in parallel. `noCache` only
removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always
made.
The 15 s grace window is therefore required, not cosmetic.
## Edge cases
- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh
the PR cache entry on tab entry when its timestamp is outside the grace window.
- **First Checks entry after app start.** Only `prCache` and `issueCache` are
persisted. `checksCache` and `commentsCache` start empty, so a known PR should
force checks/comments on entry.
- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the
entry key so same repo/branch switches still count as a new entry. The existing
render-time local reset handles stale title/loading state.
- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing
it again re-evaluates timestamps. The grace window suppresses duplicate calls.
- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll
request instead of forcing a new `noCache` request. This is intentional for now.
- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect
can still force a PR refresh. Entry refresh may also run, but PR generation
guards prevent older PR responses from overwriting newer cache entries.
- **PR head changed externally.** `handleRefresh()` must use the refreshed PR
returned by `fetchPRForBranch` before fetching checks, so checks are requested
with the current `headSha`.
- **Component unmount mid-fetch.** The comments auto-fetch guards stale
responses; the checks fetch path and `handleRefresh()` do not cancel local
setters. This feature should keep that behavior unless tests expose a warning
or stale-state regression.
- **SSH / remote repo.** No path manipulation or platform-specific shortcut code
is needed. The same IPC-backed `gh` path is used.
## Rollout
1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in
`ChecksPanel.tsx`.
2. Keep `handleRefresh()` as the single refresh implementation.
3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes,
fresh-within-grace skips, known PR with missing checks/comments refreshes,
hidden panel no-op, and hide/show same PR re-evaluation.
4. Run `pnpm typecheck && pnpm lint`.
+87
View File
@@ -0,0 +1,87 @@
# Sidebar Repo Filter Redesign
## Problem
The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction.
Constraints from current code:
- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`.
- `searchRepos()` already provides ranking (display name first, path fallback).
- The trigger badge is the primary “filters active” affordance and must stay.
## Goals
1. Make repo filtering usable with large repo sets.
2. Keep toggle filters one click.
3. Add fast bulk actions (`All`, `None`, `Clear all`).
4. Preserve existing filter semantics and badge behavior.
## Non-goals
- No semantic changes to filtering logic in `visible-worktrees.ts`.
- No sticky-all persistence model like `RepoMultiCombobox`.
- No store schema migration.
## Correctness Notes (from code audit)
- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering.
- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path.
- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents.
- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch.
## Proposed UI
Use `Popover` and keep trigger button/badge unchanged.
Popover sections:
1. Header: `Filters` + `Clear all` (shown only when any filter is active).
2. Toggle rows: `Active only`, `Hide default branch`.
3. Repo section (only when `repos.length > 1`):
- Label + selected count.
- `All` and `None` actions.
- Search box (`Search repos...`).
- Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists.
- Secondary path line for disambiguation when names collide.
4. Bottom action: `Add project` pinned below repo list.
## Implementation Decision: cmdk vs plain input
Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results.
Why:
- Better keyboard behavior out of the box (arrow navigation, enter selection).
- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions.
- Consistent behavior with an existing repo selector pattern in the app.
## Edge Cases / Invalidation
- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count.
- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render).
- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`.
- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name.
- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states.
- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization.
## Accessibility / Focus
- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators.
- Autofocus search on open.
- Esc closes via Radix Popover default.
- Ensure focus returns to trigger on close.
## Rollout
1. Replace sidebar filter content with Popover + Command-based repo list.
2. Keep `searchRepos()` as the only ranking source.
3. Keep current filter setters and badge derivation semantics.
4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container).
5. Validate with `pnpm typecheck` and `pnpm lint`.
## Test Scope
- Unit coverage remains in `visible-worktrees` for filter semantics.
- Add/adjust component tests for:
- keyboard navigation and selection,
- stale repo id handling,
- `All`/`None` behavior with live repo mutations,
- `Add project` visibility at repo count 0/1.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.48-rc.1",
"version": "1.3.48",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+149
View File
@@ -14,6 +14,7 @@ import {
import { tmpdir } from 'os'
import { join } from 'path'
import { AgentHookServer, _internals } from './server'
import { parseAgentStatusPayload } from '../../shared/agent-status-types'
const PANE = 'tab-1:0'
@@ -78,6 +79,7 @@ describe('AgentHookServer listener replay', () => {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
receivedAt: expect.any(Number),
stateStartedAt: expect.any(Number),
payload: expect.objectContaining({
@@ -156,6 +158,7 @@ describe('AgentHookServer listener replay', () => {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'repo::/tmp/worktree with "quotes"',
connectionId: null,
receivedAt: expect.any(Number),
stateStartedAt: expect.any(Number),
payload: expect.objectContaining({
@@ -1598,3 +1601,149 @@ describe('Last-status persistence', () => {
expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me')
})
})
describe('AgentHookServer ingestRemote', () => {
it('stamps connectionId and forwards a valid relay envelope to the listener', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: 'conn-1',
receivedAt: expect.any(Number),
stateStartedAt: expect.any(Number),
payload
})
)
})
it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
// Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
// constructing an obviously-invalid payload — `ingestRemote` is the trust
// boundary we're testing, not the parser.
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'nonsense', prompt: '', agentType: 'claude' }
},
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
})
it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
// 201 chars — one past the listener's 200-char cap.
const oversized = 'a'.repeat(201)
server.ingestRemote(
{ paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload },
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
})
it('rejects empty connectionId', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '')
expect(listener).not.toHaveBeenCalled()
})
it('rejects whitespace-only connectionId', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ')
expect(listener).not.toHaveBeenCalled()
})
it('rejects non-string tabId', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload },
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
})
it('rejects empty paneKey after trim', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
)
if (!payload) {
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
expect(listener).not.toHaveBeenCalled()
})
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
// Why: the relay normally normalizes the payload on the wire, but a buggy
// or malicious relay could forward an over-cap field. ingestRemote must
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
// cap (200 chars) is enforced at the trust boundary.
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' }
},
'conn-1'
)
expect(listener).toHaveBeenCalledTimes(1)
const event = listener.mock.calls[0][0] as { payload: { prompt: string } }
expect(event.payload.prompt.length).toBe(200)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- test suite covers Claude runtime auth refresh, identity guards, and snapshot restore cases */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
chmodSync,
@@ -6,6 +7,7 @@ import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
@@ -77,17 +79,22 @@ function createStore(settings: GlobalSettings) {
function createManagedClaudeAuth(
rootDir: string,
accountId: string,
credentialsJson: string
credentialsJson: string,
oauthAccountJson = `{"accountUuid":"${accountId}"}\n`
): string {
const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), `{"accountUuid":"${accountId}"}\n`)
writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8')
testState.managedKeychainCredentials.set(accountId, credentialsJson)
return managedAuthPath
}
function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManagedAccount {
function createClaudeAccount(
id: string,
managedAuthPath: string,
overrides: Partial<ClaudeManagedAccount> = {}
): ClaudeManagedAccount {
return {
id,
email: 'user@example.com',
@@ -97,10 +104,49 @@ function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManaged
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
lastAuthenticatedAt: 1,
...overrides
}
}
function createClaudeCredentialsJson(
email: string,
accessToken: string,
organizationUuid: string | null = null,
expiresAt = Date.now() + 60_000
): string {
return `${JSON.stringify({
claudeAiOauth: {
email,
...(organizationUuid ? { organizationUuid } : {}),
accessToken,
refreshToken: `${accessToken}-refresh`,
expiresAt
}
})}\n`
}
function createClaudeCredentialsWithoutEmail(
accessToken: string,
organizationUuid: string | null = null
): string {
return `${JSON.stringify({
claudeAiOauth: {
...(organizationUuid ? { organizationUuid } : {}),
accessToken,
refreshToken: `${accessToken}-refresh`,
expiresAt: Date.now() + 60_000
}
})}\n`
}
function readManagedCredentialsForTest(accountId: string, managedAuthPath: string): string | null {
if (process.platform === 'darwin') {
return testState.managedKeychainCredentials.get(accountId) ?? null
}
return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')
}
describe('ClaudeRuntimeAuthService', () => {
beforeEach(() => {
vi.resetModules()
@@ -142,13 +188,9 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n')
})
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
if (process.platform === 'win32') {
return
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
writeFileSync(runtimeCredentialsPath, '{"token":"managed"}\n', 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
@@ -162,6 +204,36 @@ describe('ClaudeRuntimeAuthService', () => {
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(existsSync(runtimeCredentialsPath)).toBe(false)
if (process.platform === 'darwin') {
expect(testState.activeKeychainCredentials).toBeNull()
}
})
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
if (process.platform === 'win32') {
return
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
'{"token":"managed"}\n'
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n')
@@ -178,4 +250,798 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n')
})
it('tightens credential file permissions when unchanged content is already present', async () => {
if (process.platform === 'win32') {
return
}
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
'{"token":"managed"}\n'
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
chmodSync(runtimeCredentialsPath, 0o644)
await service.syncForCurrentSelection()
expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600)
})
it('reads back refreshed credentials when the Claude identity still matches', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
})
it('reads back verified same-account credentials on first sync after restart', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson(
'user@example.com',
'original',
null,
1_000
)
const refreshedCredentials = createClaudeCredentialsJson(
'user@example.com',
'refreshed',
null,
2_000
)
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
})
it('rejects older same-account Claude credentials on first sync after restart', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const staleRuntimeCredentials = createClaudeCredentialsJson(
'user@example.com',
'stale',
null,
1_000
)
const managedCredentials = createClaudeCredentialsJson(
'user@example.com',
'managed-newer',
null,
2_000
)
writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials)
})
it('rejects runtime read-back from a different Claude identity', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected')
const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
})
it('rejects runtime read-back from the same Claude email in a different organization', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
})
it('rejects same-email Claude read-back using stored managed organization identity', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
})
it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected')
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials,
'{"organizationUuid":"org-b"}\n'
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
})
it('rejects no-email Claude read-back when organization identity conflicts', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
})
it('rejects unverifiable refreshed runtime credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsWithoutEmail('original')
const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
await service.syncForCurrentSelection()
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials)
})
it('restores the system default after rejecting unverifiable managed credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
const originalCredentials = createClaudeCredentialsWithoutEmail('original')
const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
await service.syncForCurrentSelection()
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
})
it('restores system default after same-identity managed Claude refresh on deselect', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1')
const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1')
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, {
organizationUuid: 'org-1'
})
]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8')
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
})
it('restores system default when stale Claude credentials are rejected on deselect', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected')
const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale')
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
selectedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' })
]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
})
it('does not persist unverifiable stale Claude credentials into another active account', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Credentials
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
account2Credentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
settings.activeClaudeManagedAccountId = 'account-2'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
})
it('does not carry the reauth read-back skip across Claude account switches', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
const account2RefreshedCredentials = createClaudeCredentialsJson(
'two@example.com',
'two-refreshed'
)
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Credentials
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
account2Credentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
service.clearLastWrittenCredentialsJson()
settings.activeClaudeManagedAccountId = 'account-2'
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(
account2RefreshedCredentials
)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials)
})
it('does not apply inactive-account Claude reauth skip to the active account', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
const account1RefreshedCredentials = createClaudeCredentialsJson(
'one@example.com',
'one-refreshed'
)
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Credentials
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
account2Credentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8')
service.clearLastWrittenCredentialsJson('account-2')
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(
account1RefreshedCredentials
)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials)
})
it('keeps external Claude logout when deselecting managed account', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
rmSync(runtimeCredentialsPath, { force: true })
testState.activeKeychainCredentials = null
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(existsSync(runtimeCredentialsPath)).toBe(false)
})
it('reads back refreshed active keychain credentials on macOS', async () => {
if (process.platform !== 'darwin') {
return
}
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
testState.activeKeychainCredentials = refreshedCredentials
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
})
it('restores system default when mismatched Claude keychain auth appears before deselect', async () => {
if (process.platform !== 'darwin') {
return
}
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
const externalKeychainCredentials = createClaudeCredentialsJson(
'external@example.com',
'external'
)
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
testState.activeKeychainCredentials = systemCredentials
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
testState.activeKeychainCredentials = externalKeychainCredentials
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
expect(testState.activeKeychainCredentials).toBe(systemCredentials)
})
it('captures a fresh system-default snapshot when re-entering managed mode', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1')
const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2')
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8')
settings.activeClaudeManagedAccountId = 'account-1'
await service.syncForCurrentSelection()
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2)
})
it('reads back refreshed credentials for the outgoing Claude account before switching', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original')
const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed')
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Original
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
account2Credentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8')
settings.activeClaudeManagedAccountId = 'account-2'
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
})
it('routes refreshed Claude credentials to the matching managed account', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original')
const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed')
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Original
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
account2Credentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
],
activeClaudeManagedAccountId: 'account-2'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
// A stale account-1 Claude process refreshed the shared runtime file after
// Orca selected account-2. Persist that refresh to account-1, then restore
// the selected account in the shared Claude runtime credentials.
writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8')
testState.activeKeychainCredentials = account1Refreshed
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
if (process.platform === 'darwin') {
expect(testState.activeKeychainCredentials).toBe(account2Credentials)
}
})
it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original')
const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed')
const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const managedAuthPath2 = createManagedClaudeAuth(
testState.userDataDir,
'account-2',
originalCredentials
)
const managedAuthPath3 = createManagedClaudeAuth(
testState.userDataDir,
'account-3',
activeCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }),
createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }),
createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' })
],
activeClaudeManagedAccountId: 'account-3'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
testState.activeKeychainCredentials = refreshedCredentials
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials)
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials)
if (process.platform === 'darwin') {
expect(testState.activeKeychainCredentials).toBe(activeCredentials)
}
})
it('clears an invalid active Claude account before launch preparation', async () => {
const settings = createSettings({
activeClaudeManagedAccountId: 'missing-account'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
const preparation = await service.prepareForClaudeLaunch()
expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null })
expect(preparation.stripAuthEnv).toBe(false)
expect(preparation.provenance).toBe('system')
})
it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
originalCredentials
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
testState.managedKeychainCredentials.set('account-1', reauthedCredentials)
writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8')
service.clearLastWrittenCredentialsJson()
await service.syncForCurrentSelection()
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials)
})
})
+362 -40
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: Claude account switching has one safety
boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and
env-patch semantics together prevents PTY launch and quota fetch paths drifting. */
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { ClaudeManagedAccount } from '../../shared/types'
@@ -30,6 +30,16 @@ type ClaudeSystemDefaultSnapshot = {
capturedAt: number
}
type ClaudeAuthIdentity = {
email: string | null
organizationUuid: string | null
}
type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' }
type ClaudeReadBackMatch =
| { kind: 'matched'; account: ClaudeManagedAccount }
| { kind: 'none' | 'ambiguous' }
export class ClaudeRuntimeAuthService {
private readonly pathResolver = new ClaudeRuntimePathResolver()
private mutationQueue: Promise<unknown> = Promise.resolve()
@@ -39,6 +49,7 @@ export class ClaudeRuntimeAuthService {
// an external login (e.g. `claude auth login`) overwrote it — so Orca adopts
// the file as the new system default instead of restoring a stale snapshot.
private lastWrittenCredentialsJson: string | null = null
private skipNextReadBackForAccountId: string | null = null
constructor(private readonly store: Store) {
this.initializeLastSyncedState()
@@ -63,7 +74,7 @@ export class ClaudeRuntimeAuthService {
await this.serializeMutation(async () => {
const settings = this.store.getSettings()
if (!settings.activeClaudeManagedAccountId) {
await this.restoreSystemDefaultSnapshot()
await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
this.lastSyncedAccountId = null
return
}
@@ -100,37 +111,66 @@ export class ClaudeRuntimeAuthService {
settings.claudeManagedAccounts,
settings.activeClaudeManagedAccountId
)
const previousAccount = this.getActiveAccount(
settings.claudeManagedAccounts,
this.lastSyncedAccountId
)
let outgoingReadBackResult: ClaudeReadBackResult = { status: 'unchanged' }
if (previousAccount && previousAccount.id !== activeAccount?.id) {
outgoingReadBackResult = await this.readBackRefreshedTokensForAccount(previousAccount, {
updateLastWrittenCredentialsJson: true
})
}
if (!activeAccount) {
if (settings.activeClaudeManagedAccountId) {
this.store.updateSettings({ activeClaudeManagedAccountId: null })
}
if (this.lastSyncedAccountId !== null) {
await this.restoreSystemDefaultSnapshot()
await this.restoreSystemDefaultSnapshot({
detectExternalLogin: outgoingReadBackResult.status !== 'rejected'
})
this.lastSyncedAccountId = null
}
return
}
await this.captureSystemDefaultSnapshotIfNeeded()
const credentialsJson = await this.readManagedCredentials(activeAccount)
let credentialsJson = await this.readManagedCredentials(activeAccount)
if (!credentialsJson) {
console.warn(
'[claude-runtime-auth] Active managed account is missing credentials, restoring system default'
)
this.store.updateSettings({ activeClaudeManagedAccountId: null })
if (this.lastSyncedAccountId !== null) {
await this.restoreSystemDefaultSnapshot()
await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
this.lastSyncedAccountId = null
}
return
}
if (this.lastSyncedAccountId === null) {
await this.captureSystemDefaultSnapshot({ force: true })
}
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
// .credentials.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
if (this.lastSyncedAccountId === activeAccount.id) {
await this.readBackRefreshedTokens(activeAccount)
if (this.skipNextReadBackForAccountId === activeAccount.id) {
this.skipNextReadBackForAccountId = null
} else {
const readBackResult = await this.readBackRefreshedTokens(credentialsJson, {
updateLastWrittenCredentialsJson: true
})
if (readBackResult.status === 'persisted') {
credentialsJson = (await this.readManagedCredentials(activeAccount)) ?? credentialsJson
}
}
}
if (this.lastSyncedAccountId !== activeAccount.id) {
this.skipNextReadBackForAccountId = null
}
this.writeRuntimeCredentials(credentialsJson)
if (process.platform === 'darwin') {
await writeActiveClaudeKeychainCredentials(credentialsJson)
@@ -143,40 +183,105 @@ export class ClaudeRuntimeAuthService {
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
clearLastWrittenCredentialsJson(): void {
this.lastWrittenCredentialsJson = null
clearLastWrittenCredentialsJson(
accountId = this.store.getSettings().activeClaudeManagedAccountId
): void {
if (accountId === this.store.getSettings().activeClaudeManagedAccountId) {
this.lastWrittenCredentialsJson = null
}
this.skipNextReadBackForAccountId = accountId
}
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
private async readBackRefreshedTokens(
baselineCredentialsJson: string,
options: { updateLastWrittenCredentialsJson: boolean }
): Promise<ClaudeReadBackResult> {
try {
if (this.lastWrittenCredentialsJson === null) {
return
const runtimeContents = await this.readRuntimeCredentialsForReadBack(baselineCredentialsJson)
if (!runtimeContents) {
return { status: 'unchanged' }
}
if (
this.lastWrittenCredentialsJson !== null &&
runtimeContents === this.lastWrittenCredentialsJson
) {
return { status: 'unchanged' }
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return
const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents)
if (match.kind !== 'matched') {
if (match.kind === 'ambiguous') {
console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back')
}
return { status: 'rejected' }
}
// Why: on cold app start we cannot tell whether matching runtime
// credentials are a fresh CLI refresh or stale state unless token
// metadata proves runtime is newer than managed storage.
if (
this.lastWrittenCredentialsJson === null &&
!this.runtimeCredentialsAreFresher(runtimeContents, baselineCredentialsJson)
) {
return { status: 'rejected' }
}
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
if (runtimeContents === this.lastWrittenCredentialsJson) {
return
}
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
} else {
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
await this.writeManagedCredentials(match.account, runtimeContents)
if (options.updateLastWrittenCredentialsJson) {
this.writeRuntimeCredentials(runtimeContents)
this.lastWrittenCredentialsJson = runtimeContents
if (process.platform === 'darwin') {
await writeActiveClaudeKeychainCredentials(runtimeContents)
}
}
return { status: 'persisted' }
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
return { status: 'rejected' }
}
}
private async readRuntimeCredentialsForReadBack(
baselineCredentialsJson: string
): Promise<string | null> {
const paths = this.pathResolver.getRuntimePaths()
const fileCredentials = existsSync(paths.credentialsPath)
? readFileSync(paths.credentialsPath, 'utf-8')
: null
if (process.platform === 'darwin') {
const keychainCredentials = await readActiveClaudeKeychainCredentials()
if (this.lastWrittenCredentialsJson === null) {
if (keychainCredentials && keychainCredentials !== baselineCredentialsJson) {
return keychainCredentials
}
if (fileCredentials && fileCredentials !== baselineCredentialsJson) {
return fileCredentials
}
return keychainCredentials ?? fileCredentials
}
if (keychainCredentials && keychainCredentials !== this.lastWrittenCredentialsJson) {
return keychainCredentials
}
}
if (!fileCredentials) {
return null
}
return fileCredentials
}
private async readBackRefreshedTokensForAccount(
account: ClaudeManagedAccount,
options: { updateLastWrittenCredentialsJson: boolean }
): Promise<ClaudeReadBackResult> {
const managedCredentialsJson = await this.readManagedCredentials(account)
if (!managedCredentialsJson) {
return { status: 'unchanged' }
}
return this.readBackRefreshedTokens(managedCredentialsJson, options)
}
private getPreparation(): ClaudeRuntimeAuthPreparation {
const settings = this.store.getSettings()
const paths = this.pathResolver.getRuntimePaths()
@@ -198,6 +303,161 @@ export class ClaudeRuntimeAuthService {
return accounts.find((account) => account.id === activeAccountId) ?? null
}
private async findManagedAccountForRuntimeCredentials(
runtimeCredentialsJson: string
): Promise<ClaudeReadBackMatch> {
const matches: ClaudeManagedAccount[] = []
for (const account of this.store.getSettings().claudeManagedAccounts) {
const managedCredentialsJson = await this.readManagedCredentials(account)
if (!managedCredentialsJson) {
continue
}
if (
this.runtimeCredentialsMatchAccount(
runtimeCredentialsJson,
account,
managedCredentialsJson,
this.readManagedOauthAccount(account)
) === 'match'
) {
matches.push(account)
}
}
if (matches.length === 1) {
return { kind: 'matched', account: matches[0] }
}
return { kind: matches.length === 0 ? 'none' : 'ambiguous' }
}
private runtimeCredentialsMatchAccount(
runtimeCredentialsJson: string,
account: ClaudeManagedAccount,
managedCredentialsJson: string,
managedOauthAccount: unknown
): 'match' | 'mismatch' | 'unverifiable' {
const identity = this.readIdentityFromCredentials(runtimeCredentialsJson)
if (!identity) {
return 'mismatch'
}
const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson)
const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
// Why: this mirrors the Codex runtime-home guard. If another Claude login
// or missed live process rewrites shared runtime credentials, do not
// persist those credentials into the selected managed account.
const selectedOrganizationUuid = this.normalizeField(
account.organizationUuid ??
managedIdentity?.organizationUuid ??
managedOauthIdentity.organizationUuid
)
if (selectedOrganizationUuid && !identity.organizationUuid) {
return 'unverifiable'
}
if (
selectedOrganizationUuid &&
identity.organizationUuid &&
selectedOrganizationUuid !== identity.organizationUuid
) {
return 'mismatch'
}
if (!identity.email) {
return 'unverifiable'
}
if (account.email && identity.email && this.normalizeField(account.email) !== identity.email) {
return 'mismatch'
}
return 'match'
}
private readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
} catch {
return null
}
const oauth = this.asRecord(parsed.claudeAiOauth)
return {
email: this.normalizeField(this.readString(oauth, 'email')),
organizationUuid: this.normalizeField(
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
)
}
}
private runtimeCredentialsAreFresher(
runtimeCredentialsJson: string,
managedCredentialsJson: string
): boolean {
const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson)
const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson)
return (
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness
)
}
private readFreshnessFromCredentials(credentialsJson: string): number | null {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
} catch {
return null
}
const oauth = this.asRecord(parsed.claudeAiOauth)
return (
this.readNumber(oauth, 'expiresAt') ??
this.readNumber(oauth, 'expires_at') ??
this.readNumber(oauth, 'expiry') ??
this.readNumber(oauth, 'expires')
)
}
private readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity {
const oauth = this.asRecord(oauthAccount)
return {
email: this.normalizeField(
this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email')
),
organizationUuid: this.normalizeField(
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
)
}
}
private asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
private readString(value: Record<string, unknown> | null, key: string): string | null {
const candidate = value?.[key]
return typeof candidate === 'string' ? candidate : null
}
private readNumber(value: Record<string, unknown> | null, key: string): number | null {
const candidate = value?.[key]
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return candidate
}
if (typeof candidate === 'string') {
const parsed = Number(candidate)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
private normalizeField(value: string | null | undefined): string | null {
if (!value) {
return null
}
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
private async readManagedCredentials(account: ClaudeManagedAccount): Promise<string | null> {
if (process.platform === 'darwin') {
return readManagedClaudeKeychainCredentials(account.id)
@@ -209,6 +469,18 @@ export class ClaudeRuntimeAuthService {
return readFileSync(credentialsPath, 'utf-8')
}
private async writeManagedCredentials(
account: ClaudeManagedAccount,
credentialsJson: string
): Promise<void> {
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, credentialsJson)
return
}
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
writeFileAtomically(credentialsPath, credentialsJson, { mode: 0o600 })
}
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
const oauthPath = join(account.managedAuthPath, 'oauth-account.json')
if (!existsSync(oauthPath)) {
@@ -221,9 +493,9 @@ export class ClaudeRuntimeAuthService {
}
}
private async captureSystemDefaultSnapshotIfNeeded(): Promise<void> {
private async captureSystemDefaultSnapshot(options: { force: boolean }): Promise<void> {
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (existsSync(snapshotPath)) {
if (!options.force && existsSync(snapshotPath)) {
return
}
@@ -241,13 +513,23 @@ export class ClaudeRuntimeAuthService {
this.writeJson(snapshotPath, snapshot)
}
private async restoreSystemDefaultSnapshot(): Promise<void> {
if (this.detectExternalLoginAndUpdateSnapshot()) {
private async restoreSystemDefaultSnapshot(options: {
detectExternalLogin: boolean
}): Promise<void> {
const externalState = options.detectExternalLogin
? await this.detectExternalLoginAndUpdateSnapshot()
: 'none'
if (externalState === 'file-logout') {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true })
if (process.platform === 'darwin') {
await deleteActiveClaudeKeychainCredentials()
}
this.lastWrittenCredentialsJson = null
return
}
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot
@@ -258,9 +540,11 @@ export class ClaudeRuntimeAuthService {
}
this.writeRuntimeOauthAccount(snapshot.configOauthAccount)
if (process.platform === 'darwin') {
await (snapshot.keychainCredentialsJson !== null
? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson)
: deleteActiveClaudeKeychainCredentials())
if (externalState !== 'keychain-change') {
await (snapshot.keychainCredentialsJson !== null
? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson)
: deleteActiveClaudeKeychainCredentials())
}
}
}
@@ -268,23 +552,43 @@ export class ClaudeRuntimeAuthService {
// the credentials file while a managed account was active. If the file
// differs from what Orca last wrote, that external login becomes the new
// system default — no manual "refresh" button needed.
private detectExternalLoginAndUpdateSnapshot(): boolean {
private async detectExternalLoginAndUpdateSnapshot(): Promise<
'none' | 'file-logout' | 'keychain-change'
> {
if (this.lastWrittenCredentialsJson === null) {
return false
return 'none'
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return false
const currentKeychainCredentials =
process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null
if (
process.platform === 'darwin' &&
currentKeychainCredentials === this.lastWrittenCredentialsJson
) {
return 'none'
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenCredentialsJson = null
return 'file-logout'
}
const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8')
const currentKeychainCredentials =
process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null
if (currentCredentials === this.lastWrittenCredentialsJson) {
return false
if (
process.platform === 'darwin' &&
currentKeychainCredentials !== this.lastWrittenCredentialsJson
) {
return 'keychain-change'
}
return 'none'
}
// External login detected — adopt current state as the new system default
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenCredentialsJson = null
return true
return 'file-logout'
}
private readRuntimeOauthAccount(): unknown {
@@ -316,10 +620,17 @@ export class ClaudeRuntimeAuthService {
mkdirSync(dirname(credentialsPath), { recursive: true })
// Why: repeated Claude spawns sync auth, but credentials rarely change.
// Skipping unchanged rewrites avoids Windows EPERM contention in #1507.
if (this.lastWrittenCredentialsJson === contents && existsSync(credentialsPath)) {
// Still verify the file because another Claude process may have rewritten
// runtime credentials since Orca last materialized them.
if (
this.lastWrittenCredentialsJson === contents &&
this.fileContentsEqual(credentialsPath, contents)
) {
this.ensureOwnerOnlyMode(credentialsPath)
return
}
if (this.fileContentsEqual(credentialsPath, contents)) {
this.ensureOwnerOnlyMode(credentialsPath)
this.lastWrittenCredentialsJson = contents
return
}
@@ -345,6 +656,17 @@ export class ClaudeRuntimeAuthService {
}
}
private ensureOwnerOnlyMode(targetPath: string): void {
if (process.platform === 'win32') {
return
}
try {
chmodSync(targetPath, 0o600)
} catch {
/* Best effort: the next atomic write will set the restrictive mode. */
}
}
private readJsonObject(targetPath: string): Record<string, unknown> {
if (!existsSync(targetPath)) {
return {}
+2 -2
View File
@@ -115,7 +115,7 @@ export class ClaudeAccountService {
claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account],
activeClaudeManagedAccountId: account.id
})
this.runtimeAuth.clearLastWrittenCredentialsJson()
this.runtimeAuth.clearLastWrittenCredentialsJson(accountId)
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId)
return this.getSnapshot()
@@ -154,7 +154,7 @@ export class ClaudeAccountService {
)
})
try {
this.runtimeAuth.clearLastWrittenCredentialsJson()
this.runtimeAuth.clearLastWrittenCredentialsJson(accountId)
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
+9
View File
@@ -100,12 +100,21 @@ function normalizeModelForPricing(model: string | null): string | null {
if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) {
return 'claude-sonnet-3-7'
}
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
// present in historical Claude Code/SDK logs read off disk. Match them so
// their cost is not silently dropped from the breakdown.
if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) {
return 'claude-sonnet-3-7'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
}
if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) {
return 'claude-haiku-3-5'
}
if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) {
return 'claude-haiku-3-5'
}
if (lower.includes('haiku-3')) {
return 'claude-haiku-3'
}
File diff suppressed because it is too large Load Diff
+369 -28
View File
@@ -5,6 +5,7 @@ semantics from drifting across PTY launch, login, and quota fetch paths. */
import {
copyFileSync,
existsSync,
chmodSync,
mkdirSync,
readdirSync,
readFileSync,
@@ -18,6 +19,26 @@ import type { CodexManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
import { writeFileAtomically } from './fs-utils'
type CodexAuthIdentity = {
email: string | null
providerAccountId: string | null
workspaceAccountId: string | null
}
type CodexSystemDefaultSnapshot = {
authJson: string | null
}
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
type CodexReadBackMatch =
| {
kind: 'matched'
account: CodexManagedAccount
managedAuthPath: string
managedAuthContents: string
}
| { kind: 'none' | 'ambiguous' }
export class CodexRuntimeHomeService {
// Why: tracks whether auth.json is currently managed by Orca. When null,
// Orca does NOT own auth.json and must not overwrite external changes
@@ -31,6 +52,7 @@ export class CodexRuntimeHomeService {
// login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as
// the new system default instead of restoring a stale snapshot.
private lastWrittenAuthJson: string | null = null
private skipNextReadBackForAccountId: string | null = null
constructor(private readonly store: Store) {
this.safeMigrateLegacyManagedState()
@@ -44,31 +66,47 @@ export class CodexRuntimeHomeService {
}
prepareForCodexLaunch(): string {
this.safeSyncForCurrentSelection()
this.syncForCurrentSelection()
return this.getRuntimeHomePath()
}
prepareForRateLimitFetch(): string {
this.safeSyncForCurrentSelection()
this.syncForCurrentSelection()
return this.getRuntimeHomePath()
}
syncForCurrentSelection(): void {
this.captureSystemDefaultSnapshotIfNeeded()
const settings = this.store.getSettings()
if (this.lastSyncedAccountId === null) {
this.captureSystemDefaultSnapshot({ force: false })
}
const activeAccount = this.getActiveAccount(
settings.codexManagedAccounts,
settings.activeCodexManagedAccountId
)
const previousAccount = this.getActiveAccount(
settings.codexManagedAccounts,
this.lastSyncedAccountId
)
let outgoingReadBackResult: CodexReadBackResult = 'unchanged'
if (previousAccount && previousAccount.id !== activeAccount?.id) {
outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, {
updateLastWrittenAuthJson: true
})
}
if (!activeAccount) {
if (settings.activeCodexManagedAccountId) {
this.store.updateSettings({ activeCodexManagedAccountId: null })
}
// Why: only restore the snapshot when transitioning FROM a managed
// account back to system default. When no managed account was ever
// active, auth.json belongs to the user and Orca must not touch it.
// This prevents overwriting external auth changes (codex login or other
// tools) on every PTY launch / rate-limit fetch.
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.restoreSystemDefaultSnapshot({
detectExternalLogin: outgoingReadBackResult !== 'rejected'
})
this.lastSyncedAccountId = null
}
return
@@ -81,20 +119,33 @@ export class CodexRuntimeHomeService {
)
this.store.updateSettings({ activeCodexManagedAccountId: null })
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
this.lastSyncedAccountId = null
}
return
}
if (this.lastSyncedAccountId === null) {
this.captureSystemDefaultSnapshot({ force: true })
}
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
if (this.lastSyncedAccountId === activeAccount.id) {
this.readBackRefreshedTokens(activeAuthPath)
if (this.skipNextReadBackForAccountId === activeAccount.id) {
this.skipNextReadBackForAccountId = null
} else {
this.readBackRefreshedTokens({
updateLastWrittenAuthJson: true
})
}
}
if (this.lastSyncedAccountId !== activeAccount.id) {
this.skipNextReadBackForAccountId = null
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}
@@ -103,35 +154,64 @@ export class CodexRuntimeHomeService {
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
clearLastWrittenAuthJson(): void {
this.lastWrittenAuthJson = null
clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void {
if (accountId === this.store.getSettings().activeCodexManagedAccountId) {
this.lastWrittenAuthJson = null
}
this.skipNextReadBackForAccountId = accountId
}
private readBackRefreshedTokens(managedAuthPath: string): void {
private readBackRefreshedTokens(options: {
updateLastWrittenAuthJson: boolean
}): CodexReadBackResult {
try {
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return
}
if (this.lastWrittenAuthJson === null) {
return
return 'unchanged'
}
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (runtimeContents === this.lastWrittenAuthJson) {
return
if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) {
return 'unchanged'
}
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
const match = this.findManagedAccountForRuntimeAuth(runtimeContents)
if (match.kind !== 'matched') {
if (match.kind === 'ambiguous') {
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
}
return 'rejected'
}
// Why: after app restart, Orca has no last-written baseline. Identity
// alone cannot prove runtime auth is newer than managed storage.
if (
this.lastWrittenAuthJson === null &&
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
) {
return 'rejected'
}
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
if (options.updateLastWrittenAuthJson) {
this.lastWrittenAuthJson = runtimeContents
}
return 'persisted'
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
return 'rejected'
}
}
private readBackRefreshedTokensForAccount(
_account: CodexManagedAccount,
options: { updateLastWrittenAuthJson: boolean }
): CodexReadBackResult {
return this.readBackRefreshedTokens(options)
}
private safeSyncForCurrentSelection(): void {
try {
this.syncForCurrentSelection()
@@ -150,6 +230,208 @@ export class CodexRuntimeHomeService {
return accounts.find((account) => account.id === activeAccountId) ?? null
}
private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch {
const matches: {
account: CodexManagedAccount
managedAuthPath: string
managedAuthContents: string
}[] = []
for (const account of this.store.getSettings().codexManagedAccounts) {
const managedAuthPath = join(account.managedHomePath, 'auth.json')
if (!existsSync(managedAuthPath)) {
continue
}
const managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
if (this.runtimeAuthMatchesAccount(runtimeAuthContents, account, managedAuthContents)) {
matches.push({ account, managedAuthPath, managedAuthContents })
}
}
if (matches.length === 1) {
return { kind: 'matched', ...matches[0] }
}
return { kind: matches.length === 0 ? 'none' : 'ambiguous' }
}
private runtimeAuthMatchesAccount(
runtimeAuthContents: string,
activeAccount: CodexManagedAccount,
managedAuthContents: string
): boolean {
const identity = this.readIdentityFromAuthContents(runtimeAuthContents)
if (!identity) {
return false
}
const managedIdentity = this.readIdentityFromAuthContents(managedAuthContents)
// Why: old live Codex PTYs can still write refreshed tokens into the
// shared runtime home after the user switches accounts. Never persist
// that write into the newly active managed account unless the auth claims
// still match the account Orca believes is selected.
const selectedEmail = this.firstNonNull(
this.normalizeField(activeAccount.email),
managedIdentity?.email
)
const selectedProviderId = this.firstNonNull(
this.normalizeField(activeAccount.providerAccountId),
managedIdentity?.providerAccountId
)
const selectedWorkspaceId = this.firstNonNull(
this.normalizeField(activeAccount.workspaceAccountId),
managedIdentity?.workspaceAccountId
)
const emailMatches = Boolean(
selectedEmail && identity.email && selectedEmail === identity.email
)
if (selectedEmail && identity.email && selectedEmail !== identity.email) {
return false
}
if (!this.identityFieldMatches(selectedProviderId, identity.providerAccountId)) {
return false
}
if (!this.identityFieldMatches(selectedWorkspaceId, identity.workspaceAccountId)) {
return false
}
const hasStrongIdentity = Boolean(
(selectedProviderId && identity.providerAccountId) ||
(selectedWorkspaceId && identity.workspaceAccountId)
)
return (
hasStrongIdentity ||
(emailMatches && !identity.providerAccountId && !identity.workspaceAccountId)
)
}
private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean {
const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents)
const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents)
return (
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness
)
}
private identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean {
return !selectedField || Boolean(runtimeField && selectedField === runtimeField)
}
private firstNonNull(...values: (string | null | undefined)[]): string | null {
return values.find((value): value is string => Boolean(value)) ?? null
}
private readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null {
let raw: Record<string, unknown>
try {
raw = JSON.parse(contents) as Record<string, unknown>
} catch {
return null
}
const tokens = this.readRecordClaim(raw, 'tokens')
const idToken = this.normalizeField(
this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken')
)
const payload = idToken ? this.parseJwtPayload(idToken) : null
const authClaims = this.readRecordClaim(payload, 'https://api.openai.com/auth')
const profileClaims = this.readRecordClaim(payload, 'https://api.openai.com/profile')
return {
email: this.normalizeField(
this.readStringClaim(payload, 'email') ?? this.readStringClaim(profileClaims, 'email')
),
providerAccountId: this.normalizeField(
this.readStringClaim(tokens, 'account_id') ??
this.readStringClaim(tokens, 'accountId') ??
this.readStringClaim(authClaims, 'chatgpt_account_id') ??
this.readStringClaim(payload, 'chatgpt_account_id')
),
workspaceAccountId: this.normalizeField(
this.readStringClaim(authClaims, 'workspace_account_id') ??
this.readStringClaim(tokens, 'account_id') ??
this.readStringClaim(tokens, 'accountId') ??
this.readStringClaim(payload, 'chatgpt_account_id')
)
}
}
private readFreshnessFromAuthContents(contents: string): number | null {
let raw: Record<string, unknown>
try {
raw = JSON.parse(contents) as Record<string, unknown>
} catch {
return null
}
const tokens = this.readRecordClaim(raw, 'tokens')
const idToken = this.normalizeField(
this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken')
)
const payload = idToken ? this.parseJwtPayload(idToken) : null
return (
this.readNumberClaim(tokens, 'expires_at') ??
this.readNumberClaim(tokens, 'expiresAt') ??
this.readNumberClaim(tokens, 'expiry') ??
this.readNumberClaim(tokens, 'expires') ??
this.readNumberClaim(payload, 'exp') ??
this.readNumberClaim(payload, 'iat')
)
}
private parseJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.')
if (parts.length < 2) {
return null
}
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4 !== 0) {
payload += '='
}
try {
const json = Buffer.from(payload, 'base64').toString('utf-8')
return JSON.parse(json) as Record<string, unknown>
} catch {
return null
}
}
private readRecordClaim(
value: Record<string, unknown> | null,
key: string
): Record<string, unknown> | null {
const claim = value?.[key]
if (!claim || typeof claim !== 'object' || Array.isArray(claim)) {
return null
}
return claim as Record<string, unknown>
}
private readStringClaim(value: Record<string, unknown> | null, key: string): string | null {
const claim = value?.[key]
return typeof claim === 'string' ? claim : null
}
private readNumberClaim(value: Record<string, unknown> | null, key: string): number | null {
const claim = value?.[key]
if (typeof claim === 'number' && Number.isFinite(claim)) {
return claim
}
if (typeof claim === 'string') {
const parsed = Number(claim)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
private normalizeField(value: string | null | undefined): string | null {
if (!value) {
return null
}
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
private safeMigrateLegacyManagedState(): void {
try {
this.migrateLegacyManagedStateIfNeeded()
@@ -330,34 +612,48 @@ export class CodexRuntimeHomeService {
writeFileAtomically(diagnosticsPath, `${existingContents}${JSON.stringify(record)}\n`)
}
private captureSystemDefaultSnapshotIfNeeded(): void {
private captureSystemDefaultSnapshot(options: { force: boolean }): void {
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (existsSync(snapshotPath)) {
if (!options.force && existsSync(snapshotPath)) {
return
}
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return
const snapshot: CodexSystemDefaultSnapshot = {
authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null
}
writeFileAtomically(snapshotPath, readFileSync(runtimeAuthPath, 'utf-8'))
writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 })
}
private restoreSystemDefaultSnapshot(): void {
private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void {
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
// auth.json while a managed account was active. If so, that external login
// becomes the new system default — skip the stale snapshot restore.
if (this.detectExternalLoginAndUpdateSnapshot()) {
if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
return
}
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
const snapshot = this.readSystemDefaultSnapshot(snapshotPath)
if (!snapshot) {
console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot')
rmSync(snapshotPath, { force: true })
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
return
}
if (snapshot.authJson === null) {
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
return
}
this.writeRuntimeAuth(snapshot.authJson)
}
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
@@ -371,7 +667,10 @@ export class CodexRuntimeHomeService {
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return false
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
}
try {
@@ -392,10 +691,52 @@ export class CodexRuntimeHomeService {
private writeRuntimeAuth(contents: string): void {
// Why: auth.json contains sensitive credentials. Restrict to owner-only
// so other users on a shared Linux/macOS machine cannot read it.
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
this.lastWrittenAuthJson = contents
return
}
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
this.lastWrittenAuthJson = contents
}
private fileContentsEqual(targetPath: string, contents: string): boolean {
try {
return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents
} catch {
return false
}
}
private ensureOwnerOnlyMode(targetPath: string): void {
if (process.platform === 'win32') {
return
}
try {
chmodSync(targetPath, 0o600)
} catch {
/* Best effort: the next atomic write will set the restrictive mode. */
}
}
private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null {
try {
const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
'authJson' in parsed &&
(typeof parsed.authJson === 'string' || parsed.authJson === null)
) {
return parsed as CodexSystemDefaultSnapshot
}
} catch {
return null
}
return null
}
clearSystemDefaultSnapshot(): void {
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
}
+2 -2
View File
@@ -106,7 +106,7 @@ export class CodexAccountService {
activeCodexManagedAccountId: account.id
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.clearLastWrittenAuthJson(account.id)
this.runtimeHome.syncForCurrentSelection()
// Why: the new account becomes active, so the previous active account is
@@ -150,7 +150,7 @@ export class CodexAccountService {
codexManagedAccounts: updatedAccounts
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.clearLastWrittenAuthJson(accountId)
this.runtimeHome.syncForCurrentSelection()
// Why: re-auth can change which actual Codex identity the managed home
+4 -3
View File
@@ -277,17 +277,18 @@ function openMainWindow(): BrowserWindow {
})
mainWindow = window
agentHookServer.setListener(
({ paneKey, tabId, worktreeId, payload, receivedAt, stateStartedAt }) => {
({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => {
if (mainWindow?.isDestroyed()) {
return
}
mainWindow?.webContents.send('agentStatus:set', {
...payload,
paneKey,
tabId,
worktreeId,
connectionId,
receivedAt,
stateStartedAt,
...payload
stateStartedAt
})
// Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn,
// and opencode's stays bare "OpenCode" — neither carries a working/idle
+17
View File
@@ -66,6 +66,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:list')
ipcMain.removeHandler('repos:add')
ipcMain.removeHandler('repos:remove')
ipcMain.removeHandler('repos:reorder')
ipcMain.removeHandler('repos:update')
ipcMain.removeHandler('repos:pickFolder')
ipcMain.removeHandler('repos:pickDirectory')
@@ -417,6 +418,22 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
ipcMain.handle(
'repos:reorder',
(_event, args: { orderedIds: string[] }): { status: 'applied' | 'rejected' } => {
// Why: validate at the IPC boundary — IPC input is untrusted and a
// permutation mismatch means the renderer's drag was stale relative to
// a concurrent add/remove. Reject so the renderer can resync.
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
const applied = store.reorderRepos(ids)
if (applied) {
notifyReposChanged(mainWindow)
return { status: 'applied' }
}
return { status: 'rejected' }
}
)
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
store.removeRepo(args.repoId)
invalidateAuthorizedRootsCache()
+32
View File
@@ -599,6 +599,38 @@ export class Store {
this.scheduleSave()
}
// Why: returns false on a stale permutation (concurrent add/remove races
// the renderer's drag) so the caller can tell the renderer to resync rather
// than persist an order that drops or duplicates ids.
reorderRepos(orderedIds: string[]): boolean {
const current = this.state.repos
if (orderedIds.length !== current.length) {
return false
}
const seen = new Set<string>()
for (const id of orderedIds) {
if (typeof id !== 'string' || seen.has(id)) {
return false
}
seen.add(id)
}
const byId = new Map<string, Repo>()
for (const r of current) {
byId.set(r.id, r)
}
const next: Repo[] = []
for (const id of orderedIds) {
const repo = byId.get(id)
if (!repo) {
return false
}
next.push(repo)
}
this.state.repos = next
this.scheduleSave()
return true
}
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Why: presets are repo-scoped, so removing the repo means the presets
+66 -59
View File
@@ -33,6 +33,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock,
autoUpdaterMock.checkForUpdates.mockReset()
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
autoUpdaterMock.setFeedURL.mockClear()
}
const autoUpdaterMock = {
@@ -91,6 +92,20 @@ vi.mock('./updater-nudge', () => ({
shouldApplyNudge: vi.fn().mockReturnValue(false)
}))
const ONE_HOUR_MS = 60 * 60 * 1000
const THIRTY_SECONDS_MS = 30 * 1000
const FRIENDLY_MESSAGE = "Couldn't reach the update server. Try again in a few minutes."
function makeBenignCheckFailure(message: string): void {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error(message))
})
return Promise.reject(new Error(message))
})
}
describe('updater check failure handling', () => {
beforeEach(() => {
vi.resetModules()
@@ -105,63 +120,53 @@ describe('updater check failure handling', () => {
isMock.dev = false
killAllPtyMock.mockReset()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('surfaces GitHub release transition errors to user-initiated checks', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub'))
})
return Promise.reject(new Error('Unable to find latest version on GitHub'))
})
it('surfaces GitHub release-transition failures with calmer copy and no short retry', async () => {
vi.useFakeTimers()
makeBenignCheckFailure('Unable to find latest version on GitHub')
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never)
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
// Why: a user-initiated benign failure must show a visible error. Silently
// sending 'idle' (or 'not-available') makes the button look broken.
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
})
)
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'not-available', userInitiated: true })
)
})
await vi.advanceTimersByTimeAsync(0)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: FRIENDLY_MESSAGE
})
)
expect(statuses).not.toContainEqual(
expect.objectContaining({ message: expect.stringContaining('Unable to find latest version') })
)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
it('surfaces missing latest-mac.yml to user-initiated checks', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit(
'error',
new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
)
})
return Promise.reject(
new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
)
})
it('surfaces missing latest-mac.yml to user-initiated checks with calmer copy', async () => {
makeBenignCheckFailure('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never)
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
@@ -171,7 +176,7 @@ describe('updater check failure handling', () => {
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
message: FRIENDLY_MESSAGE
})
)
expect(statuses).not.toContainEqual(
@@ -180,30 +185,32 @@ describe('updater check failure handling', () => {
})
})
it('silently drops background benign failures to idle', async () => {
// Why: background checks must stay quiet; only user-initiated clicks get
// an error card. This prevents noisy nag during a release transition.
autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub'))
})
return Promise.reject(new Error('Unable to find latest version on GitHub'))
})
it('silently drops background benign failures to idle and waits for the hourly retry', async () => {
vi.useFakeTimers()
makeBenignCheckFailure('Unable to find latest version on GitHub')
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdates } = await import('./updater')
setupAutoUpdater(mainWindow as never)
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdates()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
})
await vi.advanceTimersByTimeAsync(0)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
})
+1 -1
View File
@@ -206,7 +206,7 @@ describe('updater', () => {
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining('GitHub may be temporarily unavailable')
message: expect.stringContaining("Couldn't reach the update server")
})
)
})
+1 -1
View File
@@ -217,7 +217,7 @@ async function sendCheckFailureStatus(message: string, userInitiated?: boolean):
// prefixes "Could not check for updates." and Settings prefixes
// "Update check failed.", so the message here only carries the
// actionable cause.
sendErrorStatus('GitHub may be temporarily unavailable. Try again in a minute.', true)
sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true)
} else {
sendStatus({ state: 'idle' })
}
+1
View File
@@ -389,6 +389,7 @@ export type PreloadApi = {
kind?: 'git' | 'folder'
}) => Promise<{ repo: Repo } | { error: string }>
remove: (args: { repoId: string }) => Promise<void>
reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }>
update: (args: {
repoId: string
updates: Partial<
+3
View File
@@ -287,6 +287,9 @@ const api = {
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
reorder: (args: { orderedIds: string[] }): Promise<{ status: 'applied' | 'rejected' }> =>
ipcRenderer.invoke('repos:reorder', args),
update: (args: { repoId: string; updates: Record<string, unknown> }): Promise<unknown> =>
ipcRenderer.invoke('repos:update', args),
+155
View File
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { RelayAgentHookServer } from './agent-hook-server'
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
describe('RelayAgentHookServer', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: 'tab-1:0',
tabId: 'tab-1',
worktreeId: 'wt-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
})
})
expect(res.status).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
const envelope = forward.mock.calls[0][0]
expect(envelope.source).toBe('claude')
expect(envelope.paneKey).toBe('tab-1:0')
expect(envelope.tabId).toBe('tab-1')
expect(envelope.connectionId).toBeNull()
expect(envelope.payload.state).toBe('working')
expect(envelope.payload.prompt).toBe('hi')
// Why: the relay forwards body env/version verbatim so Orca's existing
// warn-once cross-build / dev-vs-prod diagnostics still fire on remote.
expect(envelope.env).toBe('remote')
expect(envelope.version).toBe('1')
} finally {
server.stop()
}
})
it('rejects requests with the wrong bearer token (403)', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port } = server.getCoordinates()
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': 'wrong'
},
body: '{}'
})
expect(res.status).toBe(403)
expect(forward).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('replays cached payloads on demand', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: 'tab-1:0',
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' }
})
})
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(1)
expect(forward).toHaveBeenCalledTimes(1)
expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me')
// Why: replay must preserve the wire envelope's env/version (and source)
// so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on
// replayed events the same as on live POST events.
expect(forward.mock.calls[0][0].source).toBe('claude')
expect(forward.mock.calls[0][0].env).toBe('remote')
expect(forward.mock.calls[0][0].version).toBe('1')
} finally {
server.stop()
}
})
it('does not replay paneKeys after clearPaneState', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: 'tab-1:0',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' }
})
})
server.clearPaneState('tab-1:0')
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(0)
expect(forward).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const env = server.buildPtyEnv()
expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/)
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote')
expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1')
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
} finally {
server.stop()
}
})
})
+265
View File
@@ -0,0 +1,265 @@
// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts
// a loopback HTTP server (same shape as Orca's main-process server: bind
// 127.0.0.1:0, bearer-token auth, /hook/<source> routing) and forwards every
// parsed payload via a callback so `relay.ts` can re-emit it as an
// `agent.hook` JSON-RPC notification across the existing SSH channel.
//
// Per-instance state (warn-once Sets, last-status cache, last-prompt /
// last-tool caches) lives on `HookListenerState`. The cache is bounded to one
// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3,
// request-driven replay) for the rationale.
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
createHookListenerState,
getEndpointFileName,
HOOK_REQUEST_SLOWLORIS_MS,
normalizeHookPayload,
readRequestBody,
resolveHookSource,
writeEndpointFile,
type AgentHookEventPayload,
type HookListenerState
} from '../shared/agent-hook-listener'
import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay'
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
// Why: relay's userData equivalent. Lives under $HOME so each user on a
// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR
// from `ssh-relay-deploy.ts` but stays local to this module — the hook
// server is the only consumer.
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
const RELAY_HOOKS_SUBDIR = 'agent-hooks'
function defaultEndpointDir(): string {
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
}
export type RelayHookServerOptions = {
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
endpointDir?: string
/** Env tag forwarded into hook payloads (warn-once cross-build diagnostic).
* Defaults to "remote" — distinct from Orca's local 'production'/'development'. */
env?: string
/** Called once per parsed payload. The relay wires this to
* `dispatcher.notify('agent.hook', envelope)`. */
forward: RelayHookForward
}
export class RelayAgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
private token = ''
private env: string
private endpointDir: string
private endpointFilePath: string
private endpointFileWritten = false
private state: HookListenerState = createHookListenerState()
// Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores
// `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit
// the original `source`/`env`/`version` so Orca's warn-once diagnostics fire
// identically to the live POST path. Keep this as a per-instance sidecar map
// so the shared listener type stays unchanged. Invariant: every key present
// in `state.lastStatusByPaneKey` must also be present here — populated and
// cleared in lockstep on the live POST path, clearPaneState, and stop().
private lastEnvelopeMetaByPaneKey: Map<
string,
{ source: AgentHookSource; env?: string; version?: string }
> = new Map()
private forward: RelayHookForward
constructor(options: RelayHookServerOptions) {
this.env = options.env ?? 'remote'
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
this.forward = options.forward
}
async start(): Promise<void> {
if (this.server) {
return
}
this.token = randomUUID()
this.endpointFileWritten = false
this.server = createServer((req, res) => this.handleRequest(req, res))
await new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
reject(err)
}
const onListening = (): void => {
this.server?.off('error', onStartupError)
this.server?.on('error', (err) => {
process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`)
})
const address = this.server!.address()
if (address && typeof address === 'object') {
this.port = address.port
}
this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION
})
resolve()
}
this.server!.once('error', onStartupError)
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
// the agent CLI inside the same remote box reaches us via curl
// 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(0, '127.0.0.1', onListening)
})
}
stop(): void {
this.server?.close()
this.server = null
this.port = 0
this.token = ''
this.endpointFileWritten = false
clearAllListenerCaches(this.state)
this.lastEnvelopeMetaByPaneKey.clear()
}
/** Request-driven replay: walks the per-paneKey last-payload cache and
* forwards each entry as a fresh notification. Called after Orca has
* re-wired its `agent.hook` handler on the new mux post-`--connect`.
* The relay-driver issues the replay forwards BEFORE returning from the
* request handler so the response strictly trails all replayed
* notifications on the dispatcher's single write callback. */
replayCachedPayloadsForPanes(): number {
let count = 0
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
// Why: invariant — every paneKey in the shared status cache is populated
// in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing,
// something has drifted; skip rather than fall back to a guessed source
// that would mis-tag the event downstream.
if (!meta) {
continue
}
this.forwardEvent(event, meta.source, meta.env, meta.version)
count++
}
return count
}
/** Drop a paneKey's cached entries on PTY exit so a terminated pane never
* resurfaces as a ghost event on a later reconnect. Symmetric with the
* local server's clearPaneState on PTY teardown. */
clearPaneState(paneKey: string): void {
clearPaneCacheState(this.state, paneKey)
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
}
/** Env vars to inject into every relay-spawned PTY so the hook script /
* in-process plugin POSTs to this loopback server. */
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
if (this.endpointFileWritten) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath
}
return env
}
/** Test-only / diagnostics accessor. */
getCoordinates(): { port: number; token: string; endpointFilePath: string } {
return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath }
}
// ─── Private ──────────────────────────────────────────────────────
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
res.writeHead(403)
res.end()
return
}
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
req.destroy()
})
try {
const body = await readRequestBody(req)
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
const source = resolveHookSource(pathname)
if (!source) {
res.writeHead(404)
res.end()
return
}
const event = normalizeHookPayload(this.state, source, body, this.env)
if (event) {
this.state.lastStatusByPaneKey.set(event.paneKey, event)
// TODO: once normalizeHookPayload returns validated env/version, drop
// bodyEnv/bodyVersion and source those from the listener result instead.
const env = this.bodyEnv(body)
const version = this.bodyVersion(body)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
this.forwardEvent(event, source, env, version)
}
res.writeHead(204)
res.end()
} catch {
// Why: agent hooks must fail open — return success on parse / size /
// timeout errors so a buggy agent script never blocks the agent run.
res.writeHead(204)
res.end()
}
}
private forwardEvent(
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
): void {
const envelope: AgentHookRelayEnvelope = {
source,
paneKey: event.paneKey,
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
env,
version,
payload: event.payload
}
this.forward(envelope)
}
private bodyEnv(body: unknown): string | undefined {
if (typeof body !== 'object' || body === null) {
return undefined
}
const v = (body as Record<string, unknown>).env
return typeof v === 'string' && v.length > 0 ? v : undefined
}
private bodyVersion(body: unknown): string | undefined {
if (typeof body !== 'object' || body === null) {
return undefined
}
const v = (body as Record<string, unknown>).version
return typeof v === 'string' && v.length > 0 ? v : undefined
}
}
+137 -100
View File
@@ -1,5 +1,14 @@
/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, {
Suspense,
lazy,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
} from 'react'
import {
AlignJustify,
ArrowDown,
@@ -509,6 +518,23 @@ type WorkItemDetailsCacheEntry = {
}
const workItemDetailsCache = new Map<string, WorkItemDetailsCacheEntry>()
// Why: drawers subscribe via useSyncExternalStore so reopening a cached item
// paints synchronously on first render. Stability of the snapshot relies on
// every cache write replacing the entry object identity (delete+set), which
// touchWorkItemDetailsCache already does.
const workItemDetailsCacheListeners = new Set<() => void>()
function subscribeWorkItemDetailsCache(listener: () => void): () => void {
workItemDetailsCacheListeners.add(listener)
return () => {
workItemDetailsCacheListeners.delete(listener)
}
}
function notifyWorkItemDetailsCache(): void {
for (const listener of workItemDetailsCacheListeners) {
listener()
}
}
function getWorkItemDetailsCacheKey(args: {
repoPath: string
issueSourcePreference: string | undefined
@@ -532,13 +558,20 @@ function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry
}
workItemDetailsCache.delete(oldest)
}
notifyWorkItemDetailsCache()
}
// Why: exposed so mutation handlers (in this file and elsewhere) can drop a
// stale entry after a successful local mutation. Cross-window invalidation
// arrives via the `gh:workItemMutated` event listener installed below.
export function invalidateWorkItemDetailsCacheForKey(key: string): void {
workItemDetailsCache.delete(key)
// Why: bump generation so an in-flight fetch launched before this exact-key
// invalidation will not write its stale result back into the cache.
workItemDetailsCacheGeneration += 1
const existed = workItemDetailsCache.delete(key)
if (existed) {
notifyWorkItemDetailsCache()
}
}
// Why: monotonically increases on every invalidation so an in-flight refetch
@@ -559,11 +592,16 @@ function invalidateWorkItemDetailsCacheByMatch(args: {
workItemDetailsCacheGeneration += 1
const suffix = `\0${args.type}\0${args.number}`
const prefix = `${args.repoPath}\0`
let removed = false
for (const key of Array.from(workItemDetailsCache.keys())) {
if (key.startsWith(prefix) && key.endsWith(suffix)) {
workItemDetailsCache.delete(key)
removed = true
}
}
if (removed) {
notifyWorkItemDetailsCache()
}
}
// Why: install once at module load — every dialog instance shares the cache,
@@ -2282,7 +2320,7 @@ function GHCommentComposer({
} finally {
setSubmitting(false)
}
}, [autoGrow, body, repoPath, issueNumber, onCommentAdded])
}, [autoGrow, body, repoPath, issueNumber, itemType, onCommentAdded])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -2397,9 +2435,6 @@ export default function GitHubItemDialog({
onClose
}: GitHubItemDialogProps): React.JSX.Element {
const [tab, setTab] = useState<ItemDialogTab>('conversation')
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('flat')
@@ -2439,7 +2474,6 @@ export default function GitHubItemDialog({
}
}, [workItemId, workItemState, workItemLabels])
const requestIdRef = useRef(0)
// Why: track comments added optimistically before the detail fetch resolves
// so they can be merged into the fetch result instead of being overwritten.
const optimisticCommentsRef = useRef<PRComment[]>([])
@@ -2477,22 +2511,81 @@ export default function GitHubItemDialog({
}
}, [workItem])
// Why: subscribe to the module-level cache so reopening a cached item
// paints synchronously on first render. getSnapshot returns the entry
// object directly — touchWorkItemDetailsCache writes always replace entry
// identity (delete+set), so Map.get is referentially stable between writes.
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
useCallback(
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined),
[detailsCacheKey]
)
)
// Why: bumped by appendOptimisticComment on cold open (no cached details
// yet) so the details memo re-runs and surfaces the optimistic comment via
// the loading-shell fallback. Without this, the comment would sit in the
// ref alone and not render until the in-flight fetch lands. The cache
// notify path handles the warm case.
const [optimisticTick, setOptimisticTick] = useState(0)
// Why: merge optimistic comments into the cached details. Keyed off
// cachedEntry identity (stable) rather than the optimistic ref array (a
// fresh array each render) to avoid unnecessary recomputation. Cache
// notifications after optimistic writes will re-render this anyway.
const details = useMemo<GitHubWorkItemDetails | null>(() => {
const cachedDetails = cachedEntry?.details ?? null
const opt = optimisticCommentsRef.current
if (!cachedDetails) {
// Why: details may still be loading on a cold open — surface optimistic
// comments via a minimal shell so a comment posted before the fetch
// resolves isn't held invisibly in ref-land.
if (opt.length > 0 && workItem) {
return { item: workItem, body: '', comments: [...opt] }
}
return null
}
if (opt.length === 0) {
return cachedDetails
}
const ids = new Set(cachedDetails.comments.map((c) => c.id))
const missing = opt.filter((c) => !ids.has(c.id))
if (missing.length === 0) {
return cachedDetails
}
return { ...cachedDetails, comments: [...cachedDetails.comments, ...missing] }
// Why: optimisticTick is the rerender signal for cold-open writes — the
// memo reads optimisticCommentsRef.current (a ref, no subscription), so
// bumping the tick is what forces this memo to re-run. The lint flags it
// as "unnecessary" because it's not referenced in the body, but removing
// it would silently break the cold-open optimistic-shell path.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedEntry, workItem, optimisticTick])
const loading = !!cachedEntry?.pending && !cachedEntry?.details
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
// Why: if a cross-window mutation invalidates the open drawer's entry
// (cachedEntry becomes undefined while workItem is still set), the main
// fetch effect won't re-run because its deps haven't changed. Bump a local
// tick so the fetch effect fires a refetch in that case.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
if (workItem && detailsCacheKey && !cachedEntry) {
setRefetchTick((n) => n + 1)
}
}, [workItem, detailsCacheKey, cachedEntry])
useEffect(() => {
if (!workItem || !repoPath || !detailsCacheKey) {
setDetails(null)
setError(null)
return
}
// Why: if the user clicks through several rows quickly, discard stale
// responses by tagging each request with a monotonic id and only applying
// results whose id matches the latest one.
requestIdRef.current += 1
const requestId = requestIdRef.current
// Why: only clear optimistic comments when switching to a genuinely
// different item. When reopening the same item (close → reopen), the
// gh API's 60s response cache will return stale data that omits the
// just-posted comment — preserving the optimistic ref lets the merge
// logic below re-attach it to the stale response.
// logic above re-attach it to the stale response.
if (workItem.id !== prevItemIdRef.current) {
optimisticCommentsRef.current = []
}
@@ -2503,28 +2596,6 @@ export default function GitHubItemDialog({
const now = Date.now()
const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS
// Why: paint cached data immediately when we have it so reopen feels
// instant. Only fall back to a blocking spinner when there's nothing to
// show. Optimistic comments still merge below for both paths.
if (cached?.details) {
const opt = optimisticCommentsRef.current
let painted = cached.details
if (opt.length > 0) {
const ids = new Set(painted.comments.map((c) => c.id))
const missing = opt.filter((c) => !ids.has(c.id))
if (missing.length > 0) {
painted = { ...painted, comments: [...painted.comments, ...missing] }
}
}
setDetails(painted)
setError(null)
setLoading(false)
} else {
setDetails(null)
setError(null)
setLoading(true)
}
if (hasFreshData) {
return
}
@@ -2557,13 +2628,16 @@ export default function GitHubItemDialog({
inflight
.then((result) => {
const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration
const prev = workItemDetailsCache.get(detailsCacheKey)
if (invalidatedMidFlight) {
// Why: entry was deliberately dropped; do not recreate it. If the
// entry still exists (later open repopulated it) leave it alone too.
return
}
// Why: 404/unauthorized must not overwrite valid cached data. When the
// IPC resolves to null and we already have cached details, keep the
// stale data — only blank entries get the null payload.
const prev = workItemDetailsCache.get(detailsCacheKey)
if (invalidatedMidFlight) {
// Skip cache write entirely — the entry was deliberately dropped.
} else if (result === null && prev?.details) {
if (result === null && prev?.details) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: prev.details,
fetchedAt: prev.fetchedAt,
@@ -2576,53 +2650,24 @@ export default function GitHubItemDialog({
error: undefined
})
}
if (requestId !== requestIdRef.current) {
return
}
// Why: merge any comments the user posted optimistically while the
// detail fetch was in-flight, using id to avoid duplicates.
const opt = optimisticCommentsRef.current
let merged = result
if (opt.length > 0 && merged) {
const fetchedIds = new Set(merged.comments.map((c: PRComment) => c.id))
const missing = opt.filter((c) => !fetchedIds.has(c.id))
if (missing.length > 0) {
merged = { ...merged, comments: [...merged.comments, ...missing] }
}
}
if (merged !== null || !cached?.details) {
setDetails(merged)
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load details'
const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration
if (invalidatedMidFlight) {
return
}
const prev = workItemDetailsCache.get(detailsCacheKey)
// Why: stale-on-error — keep cached data if we have it, drop the
// pending promise so the next open can retry. Only surface the
// blocking error when nothing is cached. If invalidated mid-flight,
// don't restore stale data into the now-empty entry.
if (!invalidatedMidFlight) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: prev?.details ?? null,
fetchedAt: prev?.fetchedAt ?? 0,
error: message
})
}
if (requestId !== requestIdRef.current) {
return
}
if (!prev?.details) {
setError(message)
}
// blocking error when nothing is cached.
touchWorkItemDetailsCache(detailsCacheKey, {
details: prev?.details ?? null,
fetchedAt: prev?.fetchedAt ?? 0,
error: message
})
})
.finally(() => {
if (requestId !== requestIdRef.current) {
return
}
setLoading(false)
})
}, [repoPath, workItem, detailsCacheKey])
}, [repoPath, workItem, detailsCacheKey, refetchTick])
const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot
const body = details?.body ?? ''
@@ -2636,25 +2681,11 @@ export default function GitHubItemDialog({
// that overwrites the optimistic comment. The next dialog open (after
// cache expiry) will pick up the server-confirmed version.
optimisticCommentsRef.current.push(comment)
setDetails((prev) => {
if (prev) {
return { ...prev, comments: [...prev.comments, comment] }
}
if (!workItem) {
return prev
}
// Why: details may still be loading — create a minimal shell
// so the optimistic comment isn't silently dropped.
return {
item: workItem,
body: '',
comments: [comment]
}
})
// Why: keep the module-level cache in sync so a reopen paints the new
// comment without waiting on a refetch. Mark fetchedAt as stale (0) so
// the next open still triggers a background refresh to pick up
// server-side fields like reaction groups or thread bindings.
// Why: write through the module-level cache so subscribers (this
// drawer plus any concurrent ones on the same item) re-render with the
// optimistic comment. Mark fetchedAt as stale (0) so the next open
// still triggers a background refresh to pick up server-side fields
// like reaction groups or thread bindings.
if (detailsCacheKey) {
const prev = workItemDetailsCache.get(detailsCacheKey)
if (prev?.details) {
@@ -2665,11 +2696,17 @@ export default function GitHubItemDialog({
fetchedAt: 0,
error: undefined
})
return
}
}
}
// Why: when the cache has no details yet (still loading), no cache
// write/notify fires above. Bump local state so the details memo
// re-runs and surfaces the optimistic comment via the loading-shell
// fallback instead of holding it invisibly in the ref.
setOptimisticTick((n) => n + 1)
},
[workItem, detailsCacheKey]
[detailsCacheKey]
)
return (
@@ -287,10 +287,26 @@ export function useDiffCommentDecorator({
// stale zone ids from a dead editor. The diff effect below deliberately
// has no cleanup so comment-only changes don't cause a full zone
// rebuild; this cleanup is the single place we reset zone tracking.
for (const entry of zones.values()) {
entry.root.unmount()
}
//
// Why defer the unmount: this cleanup can run inside React's commit work
// loop (e.g. when an editor is disposed during a parent render and the
// dispose listener's setState triggers a re-render that re-runs this
// effect). A synchronous root.unmount() in that window produces React
// 19's "Attempted to synchronously unmount a root while React was
// already rendering" warning. queueMicrotask lands the unmount at the
// end of the current task, before any next render, with no visible
// delay. Clear `zones` synchronously so a subsequent editor mount sees
// empty bookkeeping immediately. This matches the deferred unmount in
// the diff-pass effect below.
const rootsToUnmount = Array.from(zones.values(), (z) => z.root)
zones.clear()
if (rootsToUnmount.length > 0) {
queueMicrotask(() => {
for (const root of rootsToUnmount) {
root.unmount()
}
})
}
// Why: editor went away — drop both the in-flight scroll request and
// the resolver closure (which captured the now-disposed editor).
pendingScrollRef.current = null
@@ -15,6 +15,7 @@ import {
ChecksList,
PRCommentsList
} from './checks-helpers'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
export default function ChecksPanel(): React.JSX.Element {
@@ -79,6 +80,21 @@ export default function ChecksPanel(): React.JSX.Element {
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
: 'unknown'
// Why: select only timestamps (not whole cache records) so the entry-refresh
// effect doesn't re-run on every cache mutation. See
// docs/refresh-on-checks-tab.md.
const prFetchedAt = useAppStore((s) =>
prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined
)
const checksCacheKey = repo && prNumber ? `${repo.path}::pr-checks::${prNumber}` : ''
const commentsCacheKey = repo && prNumber ? `${repo.path}::pr-comments::${prNumber}` : ''
const checksFetchedAt = useAppStore((s) =>
checksCacheKey ? s.checksCache[checksCacheKey]?.fetchedAt : undefined
)
const commentsFetchedAt = useAppStore((s) =>
commentsCacheKey ? s.commentsCache[commentsCacheKey]?.fetchedAt : undefined
)
// Fetch PR data when the active worktree/branch changes.
// Why: pass linkedPR so worktrees created from a PR (whose new local branch
// differs from the PR's head ref) resolve via the number-based fallback.
@@ -240,9 +256,41 @@ export default function ChecksPanel(): React.JSX.Element {
linkedPRNumber: linkedPR
})
if (refreshedPR) {
// Why: call fetchPRChecks directly with the refreshed PR's headSha so
// we don't pass the stale headSha captured by `fetchChecks`'s closure
// before the PR refresh completed (covers external force-pushes and
// PR-number changes).
const refreshedChecks = fetchPRChecks(
repo.path,
refreshedPR.number,
branch,
refreshedPR.headSha,
{ force: true }
).then(
(result) => {
setChecks(result)
const signature = JSON.stringify(
result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)
)
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
},
(err) => {
console.warn('Failed to fetch PR checks:', err)
setChecks([])
}
)
setChecksLoading(true)
const refreshedComments = fetchComments({
force: true,
prNumberOverride: refreshedPR.number
})
await Promise.all([
fetchChecks({ force: true, prNumberOverride: refreshedPR.number }),
fetchComments({ force: true, prNumberOverride: refreshedPR.number })
refreshedChecks.finally(() => setChecksLoading(false)),
refreshedComments
])
} else {
setChecks([])
@@ -251,7 +299,49 @@ export default function ChecksPanel(): React.JSX.Element {
} finally {
setIsRefreshing(false)
}
}, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments])
}, [repo, branch, linkedPR, fetchPRForBranch, fetchPRChecks, fetchComments])
// Why: force a freshness check on each "entry" into the Checks tab so PRs
// opened outside Orca, externally force-pushed heads, and stale checks/comments
// appear without waiting for the cache TTL. The grace window suppresses
// duplicate fetches from rapid show/hide toggles. See
// docs/refresh-on-checks-tab.md.
const entryKey =
isPanelVisible && repo && !isFolder && branch
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
: ''
const lastEntryKeyRef = useRef<string>('')
useEffect(() => {
if (!entryKey) {
// Resetting on hide is required so reopening the panel on the same PR
// re-evaluates freshness (a prevKey !== currentKey check alone would miss
// close-and-reopen of the same PR).
lastEntryKeyRef.current = ''
return
}
if (lastEntryKeyRef.current === entryKey) {
return
}
lastEntryKeyRef.current = entryKey
const stale = shouldEntryRefresh({
prFetchedAt,
checksFetchedAt,
commentsFetchedAt,
prNumber,
now: Date.now(),
graceMs: ENTRY_REFRESH_GRACE_MS
})
if (!stale) {
return
}
// Reset polling attention state so the forced fetch's signature establishes
// a fresh baseline rather than colliding with the previous PR's backoff.
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
void handleRefresh()
}, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleRefresh])
const handleStartEdit = useCallback(() => {
if (!pr) {
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
const NOW = 1_700_000_000_000
describe('shouldEntryRefresh', () => {
it('refreshes when PR cache is missing (cold start, no PR known)', () => {
expect(
shouldEntryRefresh({
prFetchedAt: undefined,
checksFetchedAt: undefined,
commentsFetchedAt: undefined,
prNumber: null,
now: NOW
})
).toBe(true)
})
it('refreshes when cached null PR result is older than the grace window', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
checksFetchedAt: undefined,
commentsFetchedAt: undefined,
prNumber: null,
now: NOW
})
).toBe(true)
})
it('skips when cached null PR result is within the grace window', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: undefined,
commentsFetchedAt: undefined,
prNumber: null,
now: NOW
})
).toBe(false)
})
it('refreshes when PR is known but checks cache is missing (first entry after app start)', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: undefined,
commentsFetchedAt: NOW - 1_000,
prNumber: 42,
now: NOW
})
).toBe(true)
})
it('refreshes when PR is known but comments cache is missing', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: NOW - 1_000,
commentsFetchedAt: undefined,
prNumber: 42,
now: NOW
})
).toBe(true)
})
it('refreshes when checks timestamp is older than the grace window', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
commentsFetchedAt: NOW - 1_000,
prNumber: 42,
now: NOW
})
).toBe(true)
})
it('refreshes when comments timestamp is older than the grace window', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: NOW - 1_000,
commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
prNumber: 42,
now: NOW
})
).toBe(true)
})
it('skips when PR, checks, and comments are all fresh within the grace window', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 5_000,
checksFetchedAt: NOW - 5_000,
commentsFetchedAt: NOW - 5_000,
prNumber: 42,
now: NOW
})
).toBe(false)
})
it('ignores checks/comments freshness when no PR is known', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 1_000,
checksFetchedAt: undefined,
commentsFetchedAt: undefined,
prNumber: null,
now: NOW
})
).toBe(false)
})
it('treats a PR timestamp exactly at the cutoff as fresh', () => {
// Why: the rule is "older than now - grace", strict less-than. Equal is fresh.
expect(
shouldEntryRefresh({
prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
prNumber: 42,
now: NOW
})
).toBe(false)
})
it('honors a custom graceMs override', () => {
expect(
shouldEntryRefresh({
prFetchedAt: NOW - 2_000,
checksFetchedAt: NOW - 2_000,
commentsFetchedAt: NOW - 2_000,
prNumber: 42,
now: NOW,
graceMs: 1_000
})
).toBe(true)
})
})
@@ -0,0 +1,46 @@
// Why: extracted as a pure helper so the freshness decision is unit-testable
// without mounting ChecksPanel and its many store dependencies. The rules come
// from docs/refresh-on-checks-tab.md (Grace window + Edge cases sections).
export const ENTRY_REFRESH_GRACE_MS = 15_000
export type EntryRefreshInput = {
prFetchedAt: number | undefined
checksFetchedAt: number | undefined
commentsFetchedAt: number | undefined
prNumber: number | null
now: number
graceMs?: number
}
/**
* Decide whether entering the Checks tab should trigger a force refresh.
*
* Rules:
* - Missing PR cache timestamp is stale (cold start, never fetched).
* - A cached PR timestamp older than `now - graceMs` is stale; this also
* covers cached `null` PR results, which still have a fetchedAt.
* - When a PR number is known, missing checks/comments timestamps are stale
* (their caches are not persisted, so they restart empty).
* - When no PR number is known, checks/comments timestamps are not relevant.
*/
export function shouldEntryRefresh(input: EntryRefreshInput): boolean {
const { prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, now } = input
const graceMs = input.graceMs ?? ENTRY_REFRESH_GRACE_MS
const cutoff = now - graceMs
if (prFetchedAt === undefined || prFetchedAt < cutoff) {
return true
}
if (prNumber !== null) {
if (checksFetchedAt === undefined || checksFetchedAt < cutoff) {
return true
}
if (commentsFetchedAt === undefined || commentsFetchedAt < cutoff) {
return true
}
}
return false
}
@@ -31,8 +31,9 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
},
{
title: 'Custom Sound',
description: 'Choose one local audio file for all delivered desktop notifications.',
keywords: ['notifications', 'sound', 'audio', 'ogg', 'mp3', 'wav']
description:
'Choose one local audio file (MP3, WAV, OGG, M4A, AAC, or FLAC) for all delivered desktop notifications.',
keywords: ['notifications', 'sound', 'audio', 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']
},
{
title: 'Send Test Notification',
@@ -141,6 +142,9 @@ export function NotificationsPane({
<p className="text-xs text-muted-foreground">
One local audio file for all delivered desktop notifications.
</p>
<p className="text-[11px] text-muted-foreground/80">
Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC.
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div
@@ -1,18 +1,19 @@
import React, { useCallback } from 'react'
import { Activity, GitBranch, ListFilter, FolderPlus, X } from 'lucide-react'
import React, { useCallback, useMemo, useState } from 'react'
import { Activity, Check, GitBranch, ListFilter, FolderPlus, Server, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { searchRepos } from '@/lib/repo-search'
import { cn } from '@/lib/utils'
const SidebarFilter = React.memo(function SidebarFilter() {
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
@@ -24,6 +25,17 @@ const SidebarFilter = React.memo(function SidebarFilter() {
const repos = useAppStore((s) => s.repos)
const addRepo = useAppStore((s) => s.addRepo)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [commandValue, setCommandValue] = useState('')
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
if (!next) {
setQuery('')
}
}, [])
const handleToggleRepo = useCallback(
(repoId: string) => {
setFilterRepoIds(
@@ -35,29 +47,46 @@ const SidebarFilter = React.memo(function SidebarFilter() {
[filterRepoIds, setFilterRepoIds]
)
const handleToggleActive = useCallback(
() => setShowActiveOnly(!showActiveOnly),
[showActiveOnly, setShowActiveOnly]
)
const handleToggleHideDefaultBranch = useCallback(
() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace),
[hideDefaultBranchWorkspace, setHideDefaultBranchWorkspace]
)
const canFilterRepos = repos.length > 1
// Why: derive from the current repos list so stale IDs in filterRepoIds
// (e.g. lingering after a repo is removed) don't inflate the active-filter
// count or falsely signal an applied filter.
const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : []
const hasRepoFilter = selectedRepos.length > 0
// Why: derive from current repos so stale ids (e.g. lingering after a repo
// is removed) don't inflate counts or falsely signal an applied filter.
const selectedRepoIdSet = useMemo(() => {
const set = new Set<string>()
for (const r of repos) {
if (filterRepoIds.includes(r.id)) {
set.add(r.id)
}
}
return set
}, [repos, filterRepoIds])
const selectedCount = selectedRepoIdSet.size
const hasRepoFilter = selectedCount > 0
const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter
const activeFilterCount =
(showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedRepos.length
(showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
const allSelected = canFilterRepos && selectedCount === repos.length
const clearAll = useCallback(() => {
setShowActiveOnly(false)
setHideDefaultBranchWorkspace(false)
setFilterRepoIds([])
}, [setShowActiveOnly, setHideDefaultBranchWorkspace, setFilterRepoIds])
// Why: derive ids from the live repos list at click time so a repo added
// while the popover is open is included immediately.
const selectAllRepos = useCallback(() => {
setFilterRepoIds(repos.map((r) => r.id))
}, [repos, setFilterRepoIds])
const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds])
return (
<DropdownMenu>
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
@@ -79,77 +108,176 @@ const SidebarFilter = React.memo(function SidebarFilter() {
</span>
)}
</Button>
</DropdownMenuTrigger>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{hasAnyFilter ? 'Edit filters' : 'Filter workspaces'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuCheckboxItem
checked={showActiveOnly}
onCheckedChange={handleToggleActive}
onSelect={(event) => event.preventDefault()}
>
<Activity className="size-3.5 text-muted-foreground" />
Active only
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hideDefaultBranchWorkspace}
onCheckedChange={handleToggleHideDefaultBranch}
onSelect={(event) => event.preventDefault()}
>
<GitBranch className="size-3.5 text-muted-foreground" />
Hide default branch
</DropdownMenuCheckboxItem>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
{repos.map((r) => (
<DropdownMenuCheckboxItem
key={r.id}
checked={filterRepoIds.includes(r.id)}
onCheckedChange={() => handleToggleRepo(r.id)}
onSelect={(event) => event.preventDefault()}
>
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
</DropdownMenuCheckboxItem>
))}
</>
)}
{hasAnyFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setShowActiveOnly(false)
setHideDefaultBranchWorkspace(false)
setFilterRepoIds([])
}}
<PopoverContent align="end" className="w-72 p-0">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-medium text-foreground">Filters</span>
{hasAnyFilter ? (
<button
type="button"
onClick={clearAll}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<X className="size-3.5 text-muted-foreground" />
Clear filters
</DropdownMenuItem>
</>
)}
<X className="size-3" />
Clear all
</button>
) : null}
</div>
<div className="border-t border-border/60">
<ToggleRow
icon={<Activity className="size-3.5" />}
label="Active only"
checked={showActiveOnly}
onClick={() => setShowActiveOnly(!showActiveOnly)}
/>
<ToggleRow
icon={<GitBranch className="size-3.5" />}
label="Hide default branch"
checked={hideDefaultBranchWorkspace}
onClick={() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace)}
/>
</div>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
inset
onSelect={() => {
addRepo()
}}
<div className="border-t border-border/60">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Repositories
{hasRepoFilter && (
<span className="ml-1.5 text-foreground normal-case tracking-normal">
{selectedCount} selected
</span>
)}
</span>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<button
type="button"
onClick={selectAllRepos}
className="hover:text-foreground disabled:opacity-40"
disabled={allSelected}
>
All
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={clearRepos}
className="hover:text-foreground disabled:opacity-40"
disabled={!hasRepoFilter}
>
None
</button>
</div>
</div>
<Command
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
className="bg-transparent"
>
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
Add project
</DropdownMenuItem>
</>
<CommandInput
autoFocus
placeholder="Search repos..."
value={query}
onValueChange={setQuery}
className="h-8 py-2 text-xs"
wrapperClassName="px-3"
iconClassName="h-3.5 w-3.5"
/>
<CommandList className="max-h-64">
<CommandEmpty className="py-4 text-[11px]">No repos match</CommandEmpty>
{filteredRepos.map((r) => {
const checked = selectedRepoIdSet.has(r.id)
return (
<CommandItem
key={r.id}
value={r.id}
onSelect={() => handleToggleRepo(r.id)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 shrink-0 text-muted-foreground',
checked ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<RepoDotLabel
name={r.displayName}
color={r.badgeColor}
className="max-w-full"
/>
{r.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Server className="size-2.5" />
SSH
</span>
)}
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Why: per design, "Add project" stays visible regardless of repo
count so users can recover from the 0/1-repo state where the
repo section is hidden. */}
<div className="border-t border-border/60">
<button
type="button"
onClick={() => addRepo()}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<FolderPlus className="size-3.5" />
Add project
</button>
</div>
</PopoverContent>
</Popover>
)
})
type ToggleRowProps = {
icon: React.ReactNode
label: string
checked: boolean
onClick: () => void
}
function ToggleRow({ icon, label, checked, onClick }: ToggleRowProps) {
// Why: the popover is not a true menu, so we use a plain button with
// aria-pressed rather than role="menuitemcheckbox". The visible checkmark
// carries the state for sighted users.
return (
<button
type="button"
onClick={onClick}
aria-pressed={checked}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground',
checked ? 'text-foreground' : 'text-muted-foreground'
)}
>
<Check
className={cn(
'size-3 shrink-0 text-muted-foreground',
checked ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-muted-foreground">{icon}</span>
<span className={cn(checked && 'text-foreground')}>{label}</span>
</button>
)
}
export default SidebarFilter
@@ -36,6 +36,7 @@ import {
sidebarHasActiveFilters
} from './visible-worktrees'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useRepoHeaderDrag } from './repo-header-drag'
// How long to wait after a sortEpoch bump before actually re-sorting.
// Prevents jarring position shifts when background events (AI starting work,
@@ -79,6 +80,13 @@ type VirtualizedWorktreeViewportProps = {
clearPendingRevealWorktreeId: () => void
worktrees: Worktree[]
repoMap: Map<string, Repo>
repoOrder: Map<string, number>
// The full canonical state.repos id ordering — the drag controller commits
// permutations of this list, even when some repos aren't currently visible
// (filtered out / collapsed-only). Visible-only ids would silently drop the
// hidden repos on reorder.
allRepoIds: string[]
reorderRepos: (orderedIds: string[]) => void
prCache: Record<string, unknown> | null
// Why: the viewport remounts when the row structure changes (see
// viewportResetKey) so the virtualizer's measurementsCache cannot hold
@@ -101,10 +109,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearPendingRevealWorktreeId,
worktrees,
repoMap,
repoOrder,
allRepoIds,
reorderRepos,
prCache,
scrollOffsetRef
}: VirtualizedWorktreeViewportProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Drag is only meaningful when the user is grouping by repo. When inert
// (groupBy !== 'repo'), the controller is still constructed for hook order
// stability but the handle is never rendered.
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: allRepoIds,
onCommit: reorderRepos,
getScrollContainer: () => scrollRef.current
})
const activeWorktreeRowIndex = useMemo(
() => rows.findIndex((row) => row.type === 'item' && row.worktree.id === activeWorktreeId),
[rows, activeWorktreeId]
@@ -260,7 +280,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
worktrees,
repoMap,
prCache,
new Set<string>()
new Set<string>(),
repoOrder
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
if (worktreeRows.length === 0) {
return
@@ -293,7 +314,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
virtualizer.scrollToIndex(rowIndex, { align: 'auto' })
}
},
[rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache]
[rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder]
)
useEffect(() => {
@@ -368,10 +389,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? (
<div
role="presentation"
className="pointer-events-none absolute left-2 right-2 z-10 border-t border-dashed border-muted-foreground/70"
style={{ top: `${repoDrag.state.dropIndicatorY}px` }}
/>
) : null}
{virtualItems.map((vItem) => {
const row = rows[vItem.index]
if (row.type === 'header') {
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
const isDraggingThis =
repoDrag.state.draggingRepoId !== null &&
repoDrag.state.draggingRepoId === repoIdForHeader
return (
<div
key={vItem.key}
@@ -384,8 +417,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<div
role="button"
tabIndex={0}
data-repo-header-id={repoIdForHeader}
className={cn(
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all cursor-pointer',
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all',
'cursor-pointer',
isDraggingThis &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
// First header sits directly under SidebarHeader, which already
// supplies its own spacing — only offset secondary group headers.
vItem.index !== firstHeaderIndex && 'mt-2',
@@ -401,6 +438,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
>
{row.icon ? (
<div
onPointerDown={
isRepoHeader && repoIdForHeader
? (e) => repoDrag.onHandlePointerDown(e, repoIdForHeader)
: undefined
}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
row.repo && 'text-muted-foreground'
@@ -711,10 +753,22 @@ const WorktreeList = React.memo(function WorktreeList() {
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
// Why: header order in groupBy='repo' is bound to state.repos array order so
// manual reorder is the single source of truth. The Map lets buildRows do an
// O(1) rank lookup per group without depending on Repo identity.
const repos = useAppStore((s) => s.repos)
const repoOrder = useMemo(() => {
const map = new Map<string, number>()
repos.forEach((r, i) => map.set(r.id, i))
return map
}, [repos])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
const reorderReposAction = useAppStore((s) => s.reorderRepos)
// Build flat row list for rendering
const rows: Row[] = useMemo(
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups),
[groupBy, worktrees, repoMap, prCache, collapsedGroups]
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder),
[groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder]
)
// Why: rows.length alone can stay the same when items migrate between
// groups (e.g., PR cache loads on restart and a collapsed group absorbs
@@ -824,6 +878,11 @@ const WorktreeList = React.memo(function WorktreeList() {
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
worktrees={worktrees}
repoMap={repoMap}
repoOrder={repoOrder}
allRepoIds={allRepoIds}
reorderRepos={(orderedIds) => {
void reorderReposAction(orderedIds)
}}
prCache={prCache}
scrollOffsetRef={sidebarScrollOffsetRef}
/>
@@ -0,0 +1,341 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by
// react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire
// against stale targets. With pointer events we cache the active set of repo
// header positions and compute the drop index from the live pointer Y.
export type RepoDragState = {
draggingRepoId: string | null
// Insertion index in the orderedRepoIds array where the dragged repo would
// land if released now. null while not dragging.
dropIndex: number | null
// Y coordinate (in scrollContainer's local space, i.e. relative to its
// top-left content origin including current scrollTop offset) where the
// insertion bar should be drawn. null while not dragging.
dropIndicatorY: number | null
}
const INITIAL_STATE: RepoDragState = {
draggingRepoId: null,
dropIndex: null,
dropIndicatorY: null
}
export type UseRepoHeaderDragArgs = {
orderedRepoIds: string[]
onCommit: (orderedIds: string[]) => void
// Returns the scroll container that hosts the virtualized rows. Bounding
// rects are read from this element so insertion-bar Y values stay correct
// when the sidebar is resized.
getScrollContainer: () => HTMLElement | null
}
type HeaderRect = {
repoId: string
// top/bottom in scrollContainer-local space (page-coord top minus container
// page-coord top, plus current scrollTop).
top: number
bottom: number
}
export type RepoHeaderDragController = {
state: RepoDragState
// Call from the repo header's onPointerDown. The drag does NOT start
// immediately — it arms a pending session that promotes to an active drag
// only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before
// promotion releases without committing, so the surrounding click handler
// still fires and toggles the group's collapsed state.
onHandlePointerDown: (event: React.PointerEvent<HTMLElement>, repoId: string) => void
}
// Pixels the pointer must travel before we promote a pending press into a
// real drag. Below this we treat the press as a normal click (toggle group).
const DRAG_THRESHOLD_PX = 4
export function useRepoHeaderDrag({
orderedRepoIds,
onCommit,
getScrollContainer
}: UseRepoHeaderDragArgs): RepoHeaderDragController {
const [state, setState] = useState<RepoDragState>(INITIAL_STATE)
// Tracks whether a press has begun (armed) regardless of promotion. Used
// only to gate window listeners; visible drag state lives in `state`.
const [sessionArmed, setSessionArmed] = useState(false)
// Why: endDrag reads dropIndex on pointerup, but binding the listener with
// dropIndex in deps would re-add window listeners on every pointermove.
// The ref tracks the latest computed value without invalidating the effect.
const latestDropIndexRef = useRef<number | null>(null)
latestDropIndexRef.current = state.dropIndex
// Keep callbacks stable: they read from refs so we don't re-bind window
// listeners every render.
const orderedIdsRef = useRef(orderedRepoIds)
orderedIdsRef.current = orderedRepoIds
const onCommitRef = useRef(onCommit)
onCommitRef.current = onCommit
const getContainerRef = useRef(getScrollContainer)
getContainerRef.current = getScrollContainer
const dragSessionRef = useRef<{
repoId: string
pointerId: number
headerRects: HeaderRect[]
handleEl: HTMLElement
startX: number
startY: number
// false until the pointer moves past DRAG_THRESHOLD_PX. While false the
// session exists but no drop indicator is shown and pointerup is treated
// as a click rather than a drop.
promoted: boolean
} | null>(null)
const computeDrop = useCallback(
(pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => {
const session = dragSessionRef.current
const container = getContainerRef.current()
if (!session || !container) {
return null
}
const containerRect = container.getBoundingClientRect()
// Translate pointer to container-local coords + scroll.
const localY = pointerY - containerRect.top + container.scrollTop
const rects = session.headerRects
if (rects.length === 0) {
return null
}
// Find the first header whose midpoint is below the pointer.
let insertBefore = rects.length
for (let i = 0; i < rects.length; i++) {
const mid = (rects[i].top + rects[i].bottom) / 2
if (localY < mid) {
insertBefore = i
break
}
}
// Why anchor to the target header (not midpoint between headers): the
// space between two repo group headers is filled with worktree cards,
// so the midpoint falls *inside another repo's content*. Sitting the
// indicator just above the target header keeps it at the visual top of
// where the dragged group would land.
const INDICATOR_GAP_PX = 4
const indicatorY =
insertBefore >= rects.length
? rects.at(-1)!.bottom + INDICATOR_GAP_PX
: Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX)
return { dropIndex: insertBefore, dropIndicatorY: indicatorY }
},
[]
)
const endDrag = useCallback((commit: boolean) => {
const session = dragSessionRef.current
if (!session) {
setState(INITIAL_STATE)
setSessionArmed(false)
return
}
try {
session.handleEl.releasePointerCapture(session.pointerId)
} catch {
// capture may already be released (pointercancel, element unmounted)
}
if (session.promoted) {
// After a real drag, the browser still fires a click on the header.
// Swallow exactly one click in capture phase so it doesn't toggle the
// group's collapsed state. Scope to the dragged handle (and ancestors)
// so an unrelated click that races between pointerup and the failsafe
// teardown isn't silently eaten.
const handleEl = session.handleEl
const swallow = (e: MouseEvent): void => {
const target = e.target as Node | null
if (target && handleEl.contains(target)) {
e.stopPropagation()
e.preventDefault()
}
window.removeEventListener('click', swallow, true)
}
window.addEventListener('click', swallow, true)
// Failsafe: if no click ever arrives (e.g. pointercancel), drop the
// listener after a tick so future clicks aren't silenced.
setTimeout(() => window.removeEventListener('click', swallow, true), 0)
}
// Only commit a reorder if the press was promoted into a real drag —
// otherwise the press was effectively a click, and the surrounding
// header onClick handler will toggle collapse.
const finalIndex =
commit && session.promoted && latestDropIndexRef.current !== null
? latestDropIndexRef.current
: null
dragSessionRef.current = null
setState(INITIAL_STATE)
setSessionArmed(false)
if (finalIndex === null) {
return
}
const ids = orderedIdsRef.current
const fromIndex = ids.indexOf(session.repoId)
if (fromIndex === -1) {
return
}
// Splice fromIndex out, then insert at finalIndex (adjusting if the
// removal shifted indices).
const next = ids.slice()
next.splice(fromIndex, 1)
const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex
if (insertAt === fromIndex) {
return
}
next.splice(insertAt, 0, session.repoId)
onCommitRef.current(next)
}, [])
// Window-level listeners while a session is armed — pointer capture on the
// header element ensures the events still fire even if the pointer leaves
// it. The session may be unpromoted (waiting for a movement past the
// threshold to become a real drag) or promoted (drop indicator visible).
useEffect(() => {
if (!sessionArmed) {
return
}
const onPointerMove = (e: PointerEvent): void => {
const session = dragSessionRef.current
if (!session || e.pointerId !== session.pointerId) {
return
}
if (!session.promoted) {
const dx = e.clientX - session.startX
const dy = e.clientY - session.startY
if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
return
}
session.promoted = true
setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null })
}
const drop = computeDrop(e.clientY)
if (!drop) {
return
}
setState((prev) =>
prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY
? prev
: { draggingRepoId: session.repoId, ...drop }
)
}
const onPointerUp = (e: PointerEvent): void => {
const session = dragSessionRef.current
if (!session || e.pointerId !== session.pointerId) {
return
}
endDrag(true)
}
const onPointerCancel = (e: PointerEvent): void => {
const session = dragSessionRef.current
if (!session || e.pointerId !== session.pointerId) {
return
}
endDrag(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
endDrag(false)
}
}
const onBlur = (): void => endDrag(false)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('blur', onBlur)
}
}, [sessionArmed, computeDrop, endDrag])
// From pointerdown onward (armed session, before/after promotion) force a
// grabbing cursor and disable text selection across the whole window so
// the user gets immediate feedback that the press registered, even before
// they've moved past DRAG_THRESHOLD_PX.
useEffect(() => {
if (!sessionArmed) {
return
}
const body = document.body
const prevCursor = body.style.cursor
const prevUserSelect = body.style.userSelect
body.style.cursor = 'grabbing'
body.style.userSelect = 'none'
return () => {
body.style.cursor = prevCursor
body.style.userSelect = prevUserSelect
}
}, [sessionArmed])
const onHandlePointerDown = useCallback(
(event: React.PointerEvent<HTMLElement>, repoId: string) => {
// Only react to primary button. Ignore right/middle clicks.
if (event.button !== 0) {
return
}
// Don't intercept presses that originated on a nested control (the
// `+` create-worktree button or the chevron). Those have their own
// click semantics and shouldn't arm a drag.
const target = event.target as HTMLElement | null
if (target && target !== event.currentTarget && target.closest('button')) {
return
}
const container = getContainerRef.current()
if (!container) {
return
}
// Snapshot every repo header's position in scrollContainer-local space.
// Using a snapshot (vs reading the DOM each pointermove) means the drop
// computation does not depend on those rows still being mounted —
// critical because react-virtual will unmount them as the user scrolls.
const containerRect = container.getBoundingClientRect()
const headerEls = container.querySelectorAll<HTMLElement>('[data-repo-header-id]')
const headerRects: HeaderRect[] = []
headerEls.forEach((el) => {
const id = el.getAttribute('data-repo-header-id')
if (!id) {
return
}
const rect = el.getBoundingClientRect()
headerRects.push({
repoId: id,
top: rect.top - containerRect.top + container.scrollTop,
bottom: rect.bottom - containerRect.top + container.scrollTop
})
})
headerRects.sort((a, b) => a.top - b.top)
const handleEl = event.currentTarget
try {
handleEl.setPointerCapture(event.pointerId)
} catch {
// setPointerCapture can throw if the element is detached; the global
// pointer listeners still fire, so dragging keeps working.
}
dragSessionRef.current = {
repoId,
pointerId: event.pointerId,
headerRects,
handleEl,
startX: event.clientX,
startY: event.clientY,
promoted: false
}
// Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a
// simple click on the header still toggles collapse via the surrounding
// onClick handler.
setSessionArmed(true)
},
[]
)
return { state, onHandlePointerDown }
}
@@ -137,6 +137,40 @@ describe('buildRows with pinned worktrees', () => {
})
})
describe('buildRows repo grouping order', () => {
const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha' }
const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta' }
const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma' }
const map = new Map([
[repoA.id, repoA],
[repoB.id, repoB],
[repoC.id, repoC]
])
const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
it('orders repo headers by explicit repoOrder, not first-encounter', () => {
// Worktree stream encounters in order C, A, B — but repoOrder says B, A, C.
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
[repoC.id, 2]
])
const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
it('places unknown repo ids last and sorts them by label', () => {
// Only repoB is in repoOrder; repoA and repoC fall through to label sort.
const repoOrder = new Map([[repoB.id, 0]])
const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
})
describe('WorktreeList header styles', () => {
it('does not title-case workspace group labels', () => {
const source = readFileSync(
@@ -150,7 +150,8 @@ export function buildRows(
worktrees: Worktree[],
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
collapsedGroups: Set<string>
collapsedGroups: Set<string>,
repoOrder?: Map<string, number>
): Row[] {
const result: Row[] = []
@@ -210,7 +211,28 @@ export function buildRows(
}
}
} else {
orderedGroups.push(...Array.from(grouped.entries()))
// Why: header order must follow the canonical state.repos array order, not
// first-encounter from the smart-sorted worktree stream — otherwise sorting
// or filtering side effects could shuffle which repo header appears first,
// and manual reorder would have nothing to bind to. Unknown ids (no entry
// in repoOrder) sort last by label so they remain deterministic.
const entries = Array.from(grouped.entries())
if (repoOrder) {
const rankFor = (key: string): number => {
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
const rank = repoOrder.get(repoId)
return rank === undefined ? Number.POSITIVE_INFINITY : rank
}
entries.sort((a, b) => {
const ra = rankFor(a[0])
const rb = rankFor(b[0])
if (ra !== rb) {
return ra - rb
}
return a[1].label.localeCompare(b[1].label)
})
}
orderedGroups.push(...entries)
}
for (const [key, group] of orderedGroups) {
+30 -1
View File
@@ -30,6 +30,7 @@ export type RepoSlice = {
>
) => Promise<void>
setActiveRepo: (repoId: string | null) => void
reorderRepos: (orderedIds: string[]) => Promise<void>
}
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
@@ -267,5 +268,33 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
setActiveRepo: (repoId) => set({ activeRepoId: repoId })
setActiveRepo: (repoId) => set({ activeRepoId: repoId }),
reorderRepos: async (orderedIds) => {
// Optimistically apply the new order so the sidebar updates instantly;
// resync only if main rejects (stale permutation due to a racing add/remove).
const previous = get().repos
const byId = new Map(previous.map((r) => [r.id, r]))
const next: Repo[] = []
for (const id of orderedIds) {
const repo = byId.get(id)
if (repo) {
next.push(repo)
}
}
if (next.length !== previous.length) {
// Caller passed a non-permutation — refuse to apply locally.
return
}
set({ repos: next })
try {
const result = await window.api.repos.reorder({ orderedIds })
if (result.status === 'rejected') {
await get().fetchRepos()
}
} catch (err) {
console.error('Failed to reorder repos:', err)
await get().fetchRepos()
}
}
})
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
createHookListenerState,
getEndpointFileName,
isShellSafeEndpointValue,
normalizeHookPayload,
parseFormEncodedBody,
resolveHookSource,
writeEndpointFile,
type HookListenerState
} from './agent-hook-listener'
describe('shared agent-hook-listener', () => {
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
it('parses form-encoded bodies', () => {
const decoded = parseFormEncodedBody('paneKey=tab-1%3A0&worktreeId=foo')
expect(decoded.paneKey).toBe('tab-1:0')
expect(decoded.worktreeId).toBe('foo')
})
it('routes pathnames to a known source or null', () => {
expect(resolveHookSource('/hook/claude')).toBe('claude')
expect(resolveHookSource('/hook/cursor')).toBe('cursor')
expect(resolveHookSource('/hook/unknown')).toBeNull()
expect(resolveHookSource('/')).toBeNull()
})
it('rejects shell-unsafe endpoint values', () => {
expect(isShellSafeEndpointValue('1234')).toBe(true)
expect(isShellSafeEndpointValue('abc-DEF.0_1')).toBe(true)
expect(isShellSafeEndpointValue('')).toBe(false)
expect(isShellSafeEndpointValue('foo&bar')).toBe(false)
expect(isShellSafeEndpointValue('foo bar')).toBe(false)
expect(isShellSafeEndpointValue('foo;bar')).toBe(false)
})
it('normalizes a Claude UserPromptSubmit body to a working state', () => {
const event = normalizeHookPayload(
state,
'claude',
{
paneKey: 'tab-1:0',
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }
},
'production'
)
expect(event).not.toBeNull()
expect(event!.paneKey).toBe('tab-1:0')
expect(event!.connectionId).toBeNull()
expect(event!.payload.state).toBe('working')
expect(event!.payload.prompt).toBe('hello')
expect(event!.payload.agentType).toBe('claude')
})
it('trims surrounding whitespace from extracted prompt text', () => {
const event = normalizeHookPayload(
state,
'claude',
{
paneKey: 'tab-1:0',
payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' }
},
'production'
)
expect(event).not.toBeNull()
expect(event!.payload.prompt).toBe('hi')
})
it('rejects oversized paneKey', () => {
const event = normalizeHookPayload(
state,
'claude',
{
paneKey: 'x'.repeat(300),
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
},
'production'
)
expect(event).toBeNull()
})
it('isolates caches between listener instances', () => {
const a = createHookListenerState()
const b = createHookListenerState()
normalizeHookPayload(
a,
'claude',
{ paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } },
'production'
)
// The second listener has no cached prompt for this paneKey, so a tool
// event without a fresh prompt should produce empty prompt string.
const event = normalizeHookPayload(
b,
'claude',
{
paneKey: 'p',
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/etc/hosts' }
}
},
'production'
)
expect(event).not.toBeNull()
expect(event!.payload.prompt).toBe('')
})
describe('writeEndpointFile', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'agent-hook-listener-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('writes the endpoint file atomically with the right contents and mode', () => {
const finalPath = join(dir, getEndpointFileName())
const ok = writeEndpointFile(dir, finalPath, {
port: 12345,
token: 'abcdef-0123',
env: 'production',
version: '1'
})
expect(ok).toBe(true)
const text = readFileSync(finalPath, 'utf8')
expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345')
expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123')
expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1')
// POSIX 0o600 — owner read/write only.
if (process.platform !== 'win32') {
const mode = statSync(finalPath).mode & 0o777
expect(mode).toBe(0o600)
}
})
it('refuses unsafe values', () => {
const finalPath = join(dir, getEndpointFileName())
const ok = writeEndpointFile(dir, finalPath, {
port: 12345,
token: 'safe-token',
env: 'foo&bar',
version: '1'
})
expect(ok).toBe(false)
})
})
})
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD,
ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV,
isRemoteAgentHooksEnabled,
type AgentHookRelayEnvelope
} from './agent-hook-relay'
describe('agent-hook-relay wire shape', () => {
it('encodes/decodes through JSON without losing fields', () => {
const envelope: AgentHookRelayEnvelope = {
source: 'claude',
paneKey: 'tab-1:0',
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
env: 'production',
version: '1',
payload: {
state: 'working',
prompt: 'roundtrip',
agentType: 'claude'
}
}
const decoded = JSON.parse(JSON.stringify(envelope)) as AgentHookRelayEnvelope
expect(decoded).toEqual(envelope)
expect(decoded.connectionId).toBeNull()
expect(decoded.payload.prompt).toBe('roundtrip')
})
it('exposes stable JSON-RPC method names', () => {
expect(AGENT_HOOK_NOTIFICATION_METHOD).toBe('agent.hook')
expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay')
expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins')
})
})
describe('isRemoteAgentHooksEnabled', () => {
it('is off when the env var is absent', () => {
expect(isRemoteAgentHooksEnabled({})).toBe(false)
})
it('is off for empty / "0"', () => {
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '' })).toBe(false)
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '0' })).toBe(false)
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: ' ' })).toBe(false)
})
it('is on for any other non-empty value', () => {
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '1' })).toBe(true)
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'on' })).toBe(true)
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true)
})
})
+83
View File
@@ -0,0 +1,83 @@
// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification
// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which
// produces it after the shared listener parses an HTTP POST) and by
// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`).
//
// Lives in `shared/` because the relay deliberately has no Electron dependency
// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for
// the renderer-bound IPC + installer contract; this module is the wire envelope
// between Orca's main process and the remote relay.
//
// Per the design doc:
// - The relay normalizes; Orca routes. The envelope's `payload` field has
// already been through `normalizeHookPayload` on the relay side; Orca's
// ingestRemote re-runs the canonical normalizer at the trust boundary
// (defense-in-depth) before feeding the event into the same `onAgentStatus`
// fanout the local HTTP path uses.
// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's
// local handle on an `ssh2` connection, not a wire identity. Orca stamps the
// real value on receive from `mux` identity inside `ingestRemote`.
// - The wire `version` and `env` fields are forwarded verbatim from the agent
// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod
// diagnostics still fire on remote-sourced events.
import type { ParsedAgentStatusPayload } from './agent-status-types'
// Why: the local hook server knows the discriminator from URL pathname routing
// (`/hook/<source>`); the relay equally must tag each forwarded notification
// with the same value so Orca can attribute the event back to the right CLI.
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
// without dragging Electron in (the shared listener module is the only place
// that consumes it from the relay side).
export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi'
/** Wire envelope for a single hook event flowing relay → Orca. */
export type AgentHookRelayEnvelope = {
source: AgentHookSource
paneKey: string
tabId?: string
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
connectionId: null
/** Forwarded verbatim from the agent CLI POST body (e.g. 'production',
* 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on
* remote events the same as on local. */
env?: string
/** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once
* protocol-version diagnostic fire on remote events the same as on local. */
version?: string
/** Pre-normalized status payload from the relay's `normalizeHookPayload`.
* Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at
* the trust boundary as defense-in-depth. */
payload: ParsedAgentStatusPayload
}
/** JSON-RPC notification method name carried over the relay control channel. */
export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const
/** JSON-RPC request method Orca issues after `--connect` reattach to ask the
* relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the
* design doc for the race that ruled out push-on-`setWrite`. */
export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const
/** JSON-RPC request method Orca issues at session-ready to ship the
* OpenCode/Pi plugin source files to the relay so it can materialize the
* per-PTY overlay dirs on the remote. */
export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const
/** Feature-flag env var. Read once at process start by Orca and the relay.
* Absent / empty / "0" = off; anything else = on. See §8 of the design doc
* for the gate locations. */
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
if (raw === undefined) {
return false
}
const trimmed = raw.trim()
if (trimmed.length === 0 || trimmed === '0') {
return false
}
return true
}
+5
View File
@@ -114,6 +114,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
paneKey: string
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
* Stamped only on the remote-ingest path (Orca's `ingestRemote`); the
* HTTP path always sets null because it cannot know which mux a request
* came from. See docs/design/agent-status-over-ssh.md §5. */
connectionId: string | null
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
/** Timestamp (ms) when the current state first appeared for this pane. */
+2 -2
View File
@@ -64,9 +64,9 @@ export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000
// Why: initial threshold of agents spawned (since last update) before we show
// the star-on-GitHub notification. Doubles each time the user dismisses
// without starring — e.g. 50 → 100 → 200 → 400. Past dismissals are encoded
// without starring — e.g. 35 → 70 → 140 → 280. Past dismissals are encoded
// in starNagNextThreshold, so this constant is only the first-time seed.
export const STAR_NAG_INITIAL_THRESHOLD = 50
export const STAR_NAG_INITIAL_THRESHOLD = 35
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'status',
+3 -3
View File
@@ -1181,9 +1181,9 @@ export type GlobalSettings = {
promptCacheTtlMs: number
/** Why: Codex rate-limit account routing is a durable app preference owned by
* the main process, not transient UI state. Persisting the selected managed
* homes here lets Orca resolve the correct `CODEX_HOME` before the renderer
* hydrates, while keeping this scope explicitly separate from Codex usage
* analytics and external terminal sessions. */
* auth here lets Orca prepare shared ~/.codex before the renderer hydrates,
* while keeping this scope explicitly separate from Codex usage analytics
* and external terminal sessions. */
codexManagedAccounts: CodexManagedAccount[]
activeCodexManagedAccountId: string | null
/** Why: Claude Code keeps conversations under one shared config root. Orca