Merge branch 'nwparker/term-speed-2-wake-session-reregister' into nwparker/term-speed-2-subscribe-recovery-kind

This commit is contained in:
Neil
2026-06-16 15:23:12 -07:00
2135 changed files with 227928 additions and 33685 deletions
+18 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { parseArgs, supportsBrowserPageFlag, validateCommandAndFlags } from './args'
import {
REPEATED_FLAG_SEPARATOR,
parseArgs,
supportsBrowserPageFlag,
validateCommandAndFlags
} from './args'
describe('parseArgs', () => {
it('keeps an empty string as a flag value', () => {
@@ -37,6 +42,18 @@ describe('parseArgs', () => {
expect(parsed.flags.get('json')).toBe(true)
expect(parsed.flags.get('url')).toBe('https://example.com')
})
it('preserves repeated string flags', () => {
const parsed = parseArgs(['linear', 'label', 'add', '--label', 'Bug', '--label=Regression'])
expect(parsed.flags.get('label')).toBe(`Bug${REPEATED_FLAG_SEPARATOR}Regression`)
})
it('does not apply repeated flag encoding to ordinary string flags', () => {
const parsed = parseArgs(['linear', 'list', '--workspace', 'old', '--workspace', 'new'])
expect(parsed.flags.get('workspace')).toBe('new')
})
})
describe('supportsBrowserPageFlag', () => {
+58 -4
View File
@@ -17,6 +17,52 @@ export type CommandSpec = {
}
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
export const BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'current',
'dry-run',
'enter',
'focus',
'force',
'full',
'help',
'inject',
'interrupt',
'json',
'messages',
'me',
'mobile',
'mobile-pairing',
'no-pairing',
'parent-current',
'ready',
'relations',
'restore-window',
'return-preamble',
'run-hooks',
'show-profile',
'staged',
'tasks',
'text-stdin',
'unread',
'value-stdin',
'wait'
])
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label'])
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
const existing = flags.get(name)
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
return
}
flags.set(name, value)
}
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
@@ -35,18 +81,22 @@ export function parseArgs(argv: string[]): ParsedArgs {
// treats a `--`-leading next token as a new flag, so it can't express one.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
continue
}
const flag = assignment
if (BOOLEAN_FLAGS.has(flag)) {
flags.set(flag, true)
continue
}
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
flags.set(flag, true)
continue
}
flags.set(flag, next)
setFlagValue(flags, flag, next)
i += 1
}
@@ -77,6 +127,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (
[
'automations',
'project',
'repo',
'worktree',
'terminal',
@@ -85,7 +136,8 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
'computer',
'emulator',
'note',
'diagnostics'
'diagnostics',
'linear'
].includes(commandPath[0])
) {
return false
@@ -105,6 +157,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
(commandPath.length === 1 &&
[
'automations',
'project',
'repo',
'worktree',
'terminal',
@@ -123,7 +176,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
'emulator',
'agent',
'environment',
'diagnostics'
'diagnostics',
'linear'
].includes(commandPath[0])) ||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
(commandPath.length === 2 &&
+5 -1
View File
@@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { CORE_HANDLERS } from './handlers/core'
import { AUTOMATION_HANDLERS } from './handlers/automations'
import { PROJECT_HANDLERS } from './handlers/project'
import { REPO_HANDLERS } from './handlers/repo'
import { WORKTREE_HANDLERS } from './handlers/worktree'
import { FILE_HANDLERS } from './handlers/file'
@@ -20,6 +21,7 @@ import { ENVIRONMENT_HANDLERS } from './handlers/environment'
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics'
import { EMULATOR_HANDLERS } from './handlers/emulator'
import { LINEAR_HANDLERS } from './handlers/linear'
export type HandlerContext = {
flags: Map<string, string | boolean>
@@ -36,6 +38,7 @@ function buildHandlers(): Map<string, CommandHandler> {
const groups = [
CORE_HANDLERS,
AUTOMATION_HANDLERS,
PROJECT_HANDLERS,
REPO_HANDLERS,
WORKTREE_HANDLERS,
FILE_HANDLERS,
@@ -53,7 +56,8 @@ function buildHandlers(): Map<string, CommandHandler> {
COMPUTER_HANDLERS,
AGENT_HOOK_HANDLERS,
DIAGNOSTICS_HANDLERS,
ENVIRONMENT_HANDLERS
ENVIRONMENT_HANDLERS,
LINEAR_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {
+11
View File
@@ -1,4 +1,5 @@
import { RuntimeClientError } from './runtime-client'
import { REPEATED_FLAG_SEPARATOR } from './args'
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
@@ -27,6 +28,16 @@ export function getOptionalStringFlag(
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function getRepeatedStringFlag(
flags: Map<string, string | boolean>,
name: string
): string[] {
const value = getOptionalStringFlag(flags, name)
return value === undefined
? []
: value.split(REPEATED_FLAG_SEPARATOR).filter((entry) => entry.length > 0)
}
export function getOptionalNumberFlag(
flags: Map<string, string | boolean>,
name: string
+55
View File
@@ -6,6 +6,7 @@ import { quoteCliCommandArgument } from './shell-command-quote'
import { RuntimeRpcFailureError } from './runtime-client'
import {
formatCliError,
formatAutomationShow,
formatComputerAction,
formatGetAppState,
formatTerminalRead,
@@ -13,6 +14,7 @@ import {
printResult
} from './format'
import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types'
import type { Automation } from '../shared/automations-types'
let testScreenshotDir: string | null = null
@@ -140,6 +142,59 @@ describe('formatWorktreeList', () => {
})
})
describe('formatAutomationShow', () => {
function automation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Nightly',
prompt: 'Run checks',
precheck: null,
agentId: 'codex',
projectId: 'repo-legacy',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
}
}
it('shows explicit run context before the legacy repo id', () => {
const output = formatAutomationShow({
automation: automation({
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
path: '/srv/orca'
}
})
})
expect(output).toContain('runProjectId: github:stablyai/orca')
expect(output).toContain('runHostId: runtime:gpu')
expect(output).toContain('projectHostSetupId: setup-gpu')
expect(output).toContain('runRepoId: repo-gpu')
expect(output).toContain('runPath: /srv/orca')
expect(output).toContain('legacyRepoId: repo-legacy')
expect(output).not.toContain('projectId: repo-legacy')
})
})
describe('formatTerminalRead', () => {
it('warns limited cursor reads to continue with the next cursor', () => {
const output = formatTerminalRead({
+8
View File
@@ -22,6 +22,14 @@ export {
formatListWindows
} from './computer-format'
export type { ComputerActionFollowUpTarget } from './computer-format'
export {
formatProjectHostSetupCreateResult,
formatProjectHostSetupDeleteResult,
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
formatProjectList
} from './project-format'
export {
formatTerminalClose,
formatTerminalCreate,
+105 -3
View File
@@ -7,7 +7,13 @@ import type {
AutomationSchedulePreset,
AutomationUpdateInput
} from '../../shared/automations-types'
import type { TuiAgent } from '../../shared/types'
import {
buildWorkspaceRunContext,
normalizeTaskSourceContext,
type TaskSourceContext,
type WorkspaceRunContext
} from '../../shared/task-source-context'
import type { ProjectHostSetup, TuiAgent } from '../../shared/types'
import {
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
@@ -30,6 +36,11 @@ import {
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateTarget
} from '../worktree-project-target'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
repo?: string
@@ -270,6 +281,46 @@ function getPrecheckFlag(
}
}
function getSourceContextFlag(
flags: Map<string, string | boolean>
): TaskSourceContext | null | undefined {
if (!flags.has('source-context')) {
return undefined
}
const value = flags.get('source-context')
if (typeof value !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context requires a JSON TaskSourceContext or null'
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
}
if (parsed === null) {
return null
}
if (!parsed || typeof parsed !== 'object') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context must be a JSON TaskSourceContext or null'
)
}
const sourceContext = normalizeTaskSourceContext(
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
)
if (!sourceContext) {
throw new RuntimeClientError(
'invalid_argument',
'--source-context is not a valid TaskSourceContext'
)
}
return sourceContext
}
function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
@@ -293,11 +344,25 @@ async function resolveDefaultTarget(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
assertWorkspaceTargetFlagsCompatible(flags)
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --workspace or project target flags, not both.'
)
}
const projectTarget = await resolveProjectCreateTarget(flags, client)
if (projectTarget) {
return {
repo: projectTarget.repoSelector,
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
}
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
if (repo || workspace) {
return { repo, workspace }
@@ -316,15 +381,46 @@ async function getExplicitTarget(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
assertWorkspaceTargetFlagsCompatible(flags)
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --workspace or project target flags, not both.'
)
}
const projectTarget = await resolveProjectCreateTarget(flags, client)
if (projectTarget) {
return {
repo: projectTarget.repoSelector,
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
}
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
return { repo, workspace }
}
function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext {
const runContext = buildWorkspaceRunContext({
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: setup.path
})
if (!runContext) {
throw new RuntimeClientError(
'invalid_argument',
`Project host setup is missing automation run context fields: ${setup.id}`
)
}
return runContext
}
export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
'automations list': async ({ client, json }) => {
const result = await client.call<{ automations: Automation[] }>('automation.list')
@@ -342,6 +438,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
const target = await resolveDefaultTarget(flags, cwd, client)
const sourceContext = getSourceContextFlag(flags)
const workspaceMode =
getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run')
const result = await client.call<{ automation: Automation }>('automation.create', {
@@ -349,6 +446,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
prompt: getRequiredStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
agentId: getProviderFlag(flags),
...(target.runContext ? { runContext: target.runContext } : {}),
...(sourceContext !== undefined ? { sourceContext } : {}),
repo: target.repo,
workspace: target.workspace,
workspaceMode,
@@ -364,6 +463,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
'automations edit': async ({ flags, client, cwd, json }) => {
const target = await getExplicitTarget(flags, cwd, client)
const schedule = getScheduleFlag(flags, false)
const sourceContext = getSourceContextFlag(flags)
const result = await client.call<{ automation: Automation }>('automation.update', {
id: getRequiredStringFlag(flags, 'id'),
updates: {
@@ -371,6 +471,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
prompt: getOptionalStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
agentId: getOptionalProviderFlag(flags),
...(target.runContext ? { runContext: target.runContext } : {}),
...(sourceContext !== undefined ? { sourceContext } : {}),
repo: target.repo,
workspace: target.workspace,
workspaceMode: getWorkspaceModeFlag(flags),
+585
View File
@@ -0,0 +1,585 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
) {
this.isRemote = Boolean(remotePairingCode || environmentSelector)
}
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { main } from '../index'
import { okFixture, queueFixtures } from '../test-fixtures'
describe('orca linear CLI handlers', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
process.env = { ...originalEnv }
// Why: these tests can run inside an Orca-managed terminal, which exports
// real worktree/terminal/pairing env hints; clear them so handler context
// assertions stay deterministic.
delete process.env.ORCA_WORKTREE_ID
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_PAIRING_CODE
delete process.env.ORCA_ENVIRONMENT
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('maps --full issue reads to read-only issueContext RPC', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', 'ENG-123', '--full', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
include: {
comments: true,
children: true,
attachments: true,
relations: true
},
depth: 2,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 120_000 }
)
})
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['--json', 'linear', 'issue', 'ENG-123', '--full'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: 'ENG-123',
include: expect.objectContaining({
comments: true,
children: true,
attachments: true,
relations: true
})
}),
{ timeoutMs: 120_000 }
)
})
it('passes verified current-context hints without resolving cwd for remote runtimes', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_123'
process.env.ORCA_WORKTREE_ID = 'repo::/srv/app'
process.env.ORCA_PAIRING_CODE = 'orca://pair?payload=bad'
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', '--current', '--comments', '--json'], '/client/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: undefined,
current: true,
include: expect.objectContaining({ comments: true }),
context: {
remote: true,
worktreeId: 'repo::/srv/app',
terminalHandle: 'term_123'
}
}),
{ timeoutMs: undefined }
)
})
it('rejects --depth unless children are requested', async () => {
await main(['linear', 'issue', 'ENG-123', '--depth', '3'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--depth requires --children or --full'
)
expect(process.exitCode).toBe(1)
})
it('maps search to agent search RPC with capped limit', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: 'all', limit: 50, returned: 0, limitReached: false }
})
)
await main(['linear', 'search', 'auth', '--workspace', 'all', '--limit', '500'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 50,
workspaceId: 'all'
})
})
it('maps project list to agent project RPC with capped limit', async () => {
queueFixtures(
callMock,
okFixture('req_projects', {
projects: [],
meta: {
query: 'launch',
workspaceId: 'all',
limit: 50,
returned: 0,
hasMore: false,
partial: false,
workspaceErrors: []
}
})
)
await main(
['linear', 'project', 'list', '--query', 'launch', '--workspace', 'all', '--limit', '500'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('linear.agentProjectList', {
query: 'launch',
limit: 50,
workspaceId: 'all'
})
})
it('keeps boolean flags between Linear and search from consuming the subcommand', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: undefined, limit: 1, returned: 0, limitReached: false }
})
)
await main(['linear', '--json', 'search', 'auth', '--limit', '1'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 1,
workspaceId: undefined
})
})
it('maps status writes to the agent write RPC with an explicit target', async () => {
queueFixtures(callMock, okFixture('req_status', statusSetResult()))
await main(['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueSetState',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
to: 'In Review',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('maps priority writes with Linear API priority numbering', async () => {
queueFixtures(callMock, okFixture('req_priority', taskUpdateResult('priority')))
await main(['linear', 'priority', 'set', 'ENG-123', '--to', 'urgent', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueUpdateTask',
expect.objectContaining({
input: 'ENG-123',
operation: 'priority',
priority: 1
}),
{ timeoutMs: 75_000 }
)
})
it('rejects impossible due dates before dispatch', async () => {
await main(['linear', 'due-date', 'set', 'ENG-123', '--to', '2026-02-30'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('real calendar date')
})
it('preserves repeated labels for label updates', async () => {
queueFixtures(callMock, okFixture('req_label', taskUpdateResult('labels')))
await main(
['linear', 'label', 'add', 'ENG-123', '--label', 'Bug', '--label', 'Regression', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueUpdateTask',
expect.objectContaining({
input: 'ENG-123',
operation: 'labels',
labelMode: 'add',
labels: ['Bug', 'Regression']
}),
{ timeoutMs: 75_000 }
)
})
it('requires exact write targets for issue writes', async () => {
await main(['linear', 'comment', 'add', '--body', 'done'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Pass a Linear issue id or --current'
)
expect(process.exitCode).toBe(1)
})
it('rejects --workspace all for writes before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--workspace',
'all'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--workspace all is not valid for Linear writes'
)
expect(process.exitCode).toBe(1)
})
it('reads comment bodies from stdin and passes retry write ids through', async () => {
const stdin = mockStdin(false, ['line one\n', 'line two'])
queueFixtures(callMock, okFixture('req_comment', commentAddResult()))
try {
await main(
[
'linear',
'comment',
'add',
'--current',
'--body-file',
'-',
'--reply-to',
'comment-parent',
'--write-id',
'123e4567-e89b-12d3-a456-426614174000',
'--json'
],
'/tmp/repo'
)
} finally {
stdin.restore()
}
expect(callMock).toHaveBeenCalledWith(
'linear.issueAddComment',
{
input: undefined,
current: true,
workspaceId: undefined,
body: 'line one\nline two',
replyTo: 'comment-parent',
writeId: '123e4567-e89b-12d3-a456-426614174000',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('rejects malformed write ids before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--write-id',
'not-a-uuid',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
const payload = JSON.parse(String(vi.mocked(console.log).mock.calls[0][0])) as {
error: { code: string }
}
expect(payload.error.code).toBe('linear_invalid_write_id')
})
it('maps create with parent-current and optional body flags', async () => {
queueFixtures(callMock, okFixture('req_create', createResult()))
await main(
[
'linear',
'create',
'--title',
'Follow-up bug',
'--body',
'Concrete repro',
'--parent-current',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueCreate',
{
title: 'Follow-up bug',
body: 'Concrete repro',
teamInput: undefined,
state: undefined,
assignee: undefined,
priority: undefined,
estimate: undefined,
dueDate: undefined,
labels: [],
parentInput: undefined,
parentCurrent: true,
workspaceId: undefined,
writeId: undefined,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('maps enriched create task fields', async () => {
queueFixtures(callMock, okFixture('req_create', createResult()))
await main(
[
'linear',
'create',
'--title',
'Triage bug',
'--team',
'ENG',
'--project',
'project-1',
'--state',
'Todo',
'--assignee',
'me',
'--priority',
'high',
'--estimate',
'2',
'--due-date',
'2026-06-30',
'--label',
'Bug',
'--label',
'Regression',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueCreate',
expect.objectContaining({
title: 'Triage bug',
teamInput: 'ENG',
projectInput: 'project-1',
state: 'Todo',
assignee: 'me',
priority: 2,
estimate: 2,
dueDate: '2026-06-30',
labels: ['Bug', 'Regression']
}),
{ timeoutMs: 75_000 }
)
})
it('rejects duplicate body inputs before dispatch', async () => {
await main(
['linear', 'create', '--title', 'Bug', '--body', 'one', '--body-file', 'body.md'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Use either --body or --body-file')
})
})
function issueResult(): unknown {
return {
issue: {
id: 'issue-id',
identifier: 'ENG-123',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-123',
state: { name: 'Todo' },
team: { name: 'Engineering' },
labels: []
},
meta: {
requested: {
current: false,
include: { comments: false, children: false, attachments: false, relations: false },
depth: 2
},
resolved: {
id: 'issue-id',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
}
}
function statusSetResult(): unknown {
return {
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
state: { id: 'state-review', name: 'In Review', type: 'started' },
previousState: { id: 'state-started', name: 'In Progress' },
meta: { workspaceId: 'workspace-1', alreadyInState: false }
}
}
function commentAddResult(): unknown {
return {
comment: { id: 'comment-id', url: null, parentId: 'comment-parent' },
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
meta: {
workspaceId: 'workspace-1',
bodyChars: 17,
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function createResult(): unknown {
return {
issue: {
id: 'issue-child',
identifier: 'ENG-456',
title: 'Follow-up bug',
url: 'https://linear.app/acme/issue/ENG-456',
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
state: { id: 'state-triage', name: 'Triage' },
parent: { id: 'issue-id', identifier: 'ENG-123' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function taskUpdateResult(operation: string): unknown {
return {
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
operation,
previous: {},
current: {},
meta: { workspaceId: 'workspace-1', alreadySet: false }
}
}
function mockStdin(isTTY: boolean, chunks: string[]): { restore: () => void } {
const stdin = process.stdin
const previousIsTTY = stdin.isTTY
const previousAsyncIterator = stdin[Symbol.asyncIterator]
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: isTTY
})
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = async function* () {
for (const chunk of chunks) {
yield chunk
}
return undefined
}
return {
restore: () => {
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: previousIsTTY
})
if (previousAsyncIterator) {
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = previousAsyncIterator
} else {
Reflect.deleteProperty(stdin, Symbol.asyncIterator)
}
}
}
}
+295
View File
@@ -0,0 +1,295 @@
import type {
LinearAttachRequest,
LinearAttachResult,
LinearCommentAddRequest,
LinearCommentAddResult,
LinearCreateRequest,
LinearCreateResult,
LinearIssueListRequest,
LinearIssueListResult,
LinearIssueContextResult,
LinearProjectListRequest,
LinearProjectListResult,
LinearIssueTaskUpdateRequest,
LinearIssueTaskUpdateResult,
LinearSearchResult,
LinearStatusSetRequest,
LinearStatusSetResult,
LinearTeamLabelsResult,
LinearTeamListResult,
LinearTeamMembersResult,
LinearTeamStatesResult
} from '../../shared/linear-agent-access'
import { clampLinearSearchLimit } from '../../shared/linear-agent-access'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRepeatedStringFlag,
getRequiredStringFlag
} from '../flags'
import {
buildAssigneeSetRequest,
buildIssueRequest,
buildLinearCurrentContext,
buildWriteTargetRequest,
getDueDateFlag,
getHttpUrlFlag,
getLinearListFilter,
getOptionalWriteId,
getPriorityFlag,
getRequiredNonNegativeIntegerFlag,
getRequiredRepeatedStringFlag,
readLinearBody,
rejectAllWorkspaceForWrite
} from '../linear-request-builders'
import {
formatLinearAttach,
formatLinearCommentAdd,
formatLinearCreate,
formatLinearIssue,
formatLinearIssueList,
formatLinearProjectList,
formatLinearTaskUpdate,
formatLinearSearch,
formatLinearStatusSet,
formatLinearTeamLabels,
formatLinearTeamList,
formatLinearTeamMembers,
formatLinearTeamStates,
printLinearIssueWarnings,
printLinearListWarnings,
printLinearProjectListWarnings,
printLinearSearchWarnings
} from '../linear-format'
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
const LINEAR_WRITE_TIMEOUT_MS = 75_000
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
'linear issue': async ({ flags, client, cwd, json }) => {
const request = buildIssueRequest(flags, cwd, client.isRemote)
const response = await client.call<LinearIssueContextResult>('linear.issueContext', request, {
timeoutMs: flags.get('full') === true ? ISSUE_CONTEXT_TIMEOUT_MS : undefined
})
if (!json) {
printLinearIssueWarnings(response.result)
}
printResult(response, json, formatLinearIssue)
},
'linear search': async ({ flags, client, json }) => {
const limit = clampLinearSearchLimit(getOptionalPositiveIntegerFlag(flags, 'limit'))
const response = await client.call<LinearSearchResult>('linear.agentSearchIssues', {
query: getRequiredStringFlag(flags, 'query'),
limit,
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
if (!json) {
printLinearSearchWarnings(response.result)
}
printResult(response, json, formatLinearSearch)
},
'linear team list': async ({ flags, client, json }) => {
const response = await client.call<LinearTeamListResult>('linear.agentTeamList', {
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
if (!json) {
printLinearListWarnings(response.result)
}
printResult(response, json, formatLinearTeamList)
},
'linear team members': async ({ flags, client, json }) => {
const response = await client.call<LinearTeamMembersResult>('linear.agentTeamMembers', {
teamInput: getRequiredStringFlag(flags, 'team'),
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
printResult(response, json, formatLinearTeamMembers)
},
'linear team states': async ({ flags, client, json }) => {
const response = await client.call<LinearTeamStatesResult>('linear.agentTeamStates', {
teamInput: getRequiredStringFlag(flags, 'team'),
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
printResult(response, json, formatLinearTeamStates)
},
'linear team labels': async ({ flags, client, json }) => {
const response = await client.call<LinearTeamLabelsResult>('linear.agentTeamLabels', {
teamInput: getRequiredStringFlag(flags, 'team'),
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
printResult(response, json, formatLinearTeamLabels)
},
'linear project list': async ({ flags, client, json }) => {
const limit = clampLinearSearchLimit(getOptionalPositiveIntegerFlag(flags, 'limit'))
const request: LinearProjectListRequest = {
query: getOptionalStringFlag(flags, 'query'),
limit,
workspaceId: getOptionalStringFlag(flags, 'workspace')
}
const response = await client.call<LinearProjectListResult>('linear.agentProjectList', request)
if (!json) {
printLinearProjectListWarnings(response.result)
}
printResult(response, json, formatLinearProjectList)
},
'linear list': async ({ flags, client, json }) => {
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
const filter = getLinearListFilter(flags)
const request: LinearIssueListRequest = {
filter,
teamInput: getOptionalStringFlag(flags, 'team'),
limit,
workspaceId: getOptionalStringFlag(flags, 'workspace')
}
const response = await client.call<LinearIssueListResult>('linear.agentIssueList', request)
if (!json) {
printLinearListWarnings(response.result)
}
printResult(response, json, formatLinearIssueList)
},
'linear status set': async ({ flags, client, cwd, json }) => {
const request: LinearStatusSetRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
to: getRequiredStringFlag(flags, 'to')
}
const response = await client.call<LinearStatusSetResult>('linear.issueSetState', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearStatusSet)
},
'linear assignee set': async (ctx) =>
runTaskUpdate(ctx, buildAssigneeSetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote)),
'linear assignee clear': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'assignee',
assigneeId: null
}),
'linear priority set': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'priority',
priority: getPriorityFlag(ctx.flags, 'to')
}),
'linear priority clear': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'priority',
priority: 0
}),
'linear estimate set': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'estimate',
estimate: getRequiredNonNegativeIntegerFlag(ctx.flags, 'to')
}),
'linear estimate clear': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'estimate',
estimate: null
}),
'linear due-date set': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'dueDate',
dueDate: getDueDateFlag(ctx.flags, 'to')
}),
'linear due-date clear': async (ctx) =>
runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'dueDate',
dueDate: null
}),
'linear label add': async (ctx) => runLabelUpdate(ctx, 'add'),
'linear label remove': async (ctx) => runLabelUpdate(ctx, 'remove'),
'linear label set': async (ctx) => runLabelUpdate(ctx, 'set'),
'linear comment add': async ({ flags, client, cwd, json }) => {
const body = await readLinearBody(flags, cwd, { required: true })
const request: LinearCommentAddRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
body,
replyTo: getOptionalStringFlag(flags, 'reply-to'),
writeId: getOptionalWriteId(flags)
}
const response = await client.call<LinearCommentAddResult>('linear.issueAddComment', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearCommentAdd)
},
'linear attach': async ({ flags, client, cwd, json }) => {
const request: LinearAttachRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
url: getHttpUrlFlag(flags, 'url'),
title: getOptionalStringFlag(flags, 'title'),
writeId: getOptionalWriteId(flags)
}
const response = await client.call<LinearAttachResult>('linear.issueAttachLink', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearAttach)
},
'linear create': async ({ flags, client, cwd, json }) => {
rejectAllWorkspaceForWrite(flags)
const parentInput = getOptionalStringFlag(flags, 'parent')
const parentCurrent = flags.get('parent-current') === true
if (parentInput && parentCurrent) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --parent or --parent-current, not both'
)
}
const body = await readLinearBody(flags, cwd, { required: false })
const request: LinearCreateRequest = {
title: getRequiredStringFlag(flags, 'title'),
...(body !== undefined ? { body } : {}),
teamInput: getOptionalStringFlag(flags, 'team'),
projectInput: getOptionalStringFlag(flags, 'project'),
state: getOptionalStringFlag(flags, 'state'),
assignee: getOptionalStringFlag(flags, 'assignee'),
priority: flags.has('priority') ? getPriorityFlag(flags, 'priority') : undefined,
estimate: flags.has('estimate')
? getRequiredNonNegativeIntegerFlag(flags, 'estimate')
: undefined,
dueDate: flags.has('due-date') ? getDueDateFlag(flags, 'due-date') : undefined,
labels: getRepeatedStringFlag(flags, 'label'),
parentInput,
parentCurrent,
workspaceId: getOptionalStringFlag(flags, 'workspace'),
writeId: getOptionalWriteId(flags),
context: buildLinearCurrentContext(cwd, client.isRemote)
}
const response = await client.call<LinearCreateResult>('linear.issueCreate', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearCreate)
}
}
async function runTaskUpdate(
{ client, json }: Parameters<CommandHandler>[0],
request: LinearIssueTaskUpdateRequest
): Promise<void> {
const response = await client.call<LinearIssueTaskUpdateResult>(
'linear.issueUpdateTask',
request,
{
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
}
)
printResult(response, json, formatLinearTaskUpdate)
}
function runLabelUpdate(
ctx: Parameters<CommandHandler>[0],
labelMode: 'add' | 'remove' | 'set'
): Promise<void> {
return runTaskUpdate(ctx, {
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
operation: 'labels',
labelMode,
labels: getRequiredRepeatedStringFlag(ctx.flags, 'label')
})
}
+70
View File
@@ -1,14 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
const getTerminalHandleMock = vi.hoisted(() => vi.fn())
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
afterEach(() => {
getTerminalHandleMock.mockReset()
if (originalTerminalHandle === undefined) {
delete process.env.ORCA_TERMINAL_HANDLE
} else {
@@ -59,6 +65,7 @@ describe('orchestration reset CLI handler', () => {
describe('orchestration send structured payload flags', () => {
beforeEach(() => {
callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } })
getTerminalHandleMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
})
@@ -116,6 +123,69 @@ describe('orchestration send structured payload flags', () => {
).rejects.toThrow(/structured payload/)
expect(callMock).not.toHaveBeenCalled()
})
it('rejects worker_done group sends before resolving a sender handle', async () => {
getTerminalHandleMock.mockRejectedValue(new Error('sender resolution should not run'))
await expect(
invokeSend(
new Map<string, string | boolean>([
['to', '@all'],
['subject', 'done'],
['type', 'worker_done']
])
)
).rejects.toMatchObject({
code: 'invalid_argument',
message: lifecycleGroupRecipientError('worker_done')
})
expect(getTerminalHandleMock).not.toHaveBeenCalled()
expect(callMock).not.toHaveBeenCalled()
})
it('rejects heartbeat group sends before resolving a sender handle', async () => {
getTerminalHandleMock.mockRejectedValue(new Error('sender resolution should not run'))
await expect(
invokeSend(
new Map<string, string | boolean>([
['to', '@idle'],
['subject', 'alive'],
['type', 'heartbeat']
])
)
).rejects.toMatchObject({
code: 'invalid_argument',
message: lifecycleGroupRecipientError('heartbeat')
})
expect(getTerminalHandleMock).not.toHaveBeenCalled()
expect(callMock).not.toHaveBeenCalled()
})
it('continues to allow worker_done to a concrete terminal handle', async () => {
await invokeSend(
new Map<string, string | boolean>([
['from', 'term_worker'],
['to', 'term_coord'],
['subject', 'done'],
['type', 'worker_done']
])
)
expect(callMock).toHaveBeenCalledWith('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'done',
body: undefined,
type: 'worker_done',
priority: undefined,
threadId: undefined,
payload: undefined,
devMode: false
})
})
})
describe('orchestration timeout flag validation', () => {
+15 -2
View File
@@ -14,6 +14,9 @@ import { getTerminalHandle } from '../selectors'
// parent process the subprocess is alive without flooding logs. See design
// doc §3.4.
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
// Why: test-only escape hatch so subprocess tests can verify the feature in
// under 10 s rather than needing a full 15 s silence window. Production users
@@ -162,17 +165,27 @@ function getOptionalPositiveIntegerValueFlag(
return value
}
function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void {
if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) {
throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type))
}
}
export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration send': async ({ flags, client, cwd, json }) => {
const to = getRequiredStringFlag(flags, 'to')
const type = getOptionalStringFlag(flags, 'type')
rejectLifecycleGroupRecipient(type, to)
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
>('orchestration.send', {
from,
to: getRequiredStringFlag(flags, 'to'),
to,
subject: getRequiredStringFlag(flags, 'subject'),
body: getOptionalStringFlag(flags, 'body'),
type: getOptionalStringFlag(flags, 'type'),
type,
priority: getOptionalStringFlag(flags, 'priority'),
threadId: getOptionalStringFlag(flags, 'thread-id'),
payload: getOptionalStructuredMessagePayload(flags),
+204
View File
@@ -0,0 +1,204 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupCloneArgs,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
RepoKind
} from '../../shared/types'
import type { CommandHandler } from '../dispatch'
import {
formatProjectHostSetupCreateResult,
formatProjectHostSetupDeleteResult,
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
formatProjectList,
printResult
} from '../format'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { resolveRepoPathArgument } from '../repo-path-arguments'
import { RuntimeClientError } from '../runtime-client'
function getOptionalRepoKind(flags: Map<string, string | boolean>): RepoKind | undefined {
const kind = getOptionalStringFlag(flags, 'kind')
if (kind === undefined) {
return undefined
}
if (kind === 'git' || kind === 'folder') {
return kind
}
throw new RuntimeClientError('invalid_argument', '--kind must be git or folder')
}
export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
'project list': async ({ client, json }) => {
const result = await client.call<{ projects: Project[] }>('project.list')
printResult(result, json, formatProjectList)
},
'project setups': async ({ flags, client, json }) => {
const projectFilter = getOptionalStringFlag(flags, 'project')
const hostFilter = getOptionalStringFlag(flags, 'host')
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
const setups = result.result.setups.filter(
(setup) =>
(projectFilter === undefined || setup.projectId === projectFilter) &&
(hostFilter === undefined || setup.hostId === hostFilter)
)
printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList)
},
'project setup-existing-folder': async ({ flags, client, cwd, json }) => {
const rawPath = getRequiredStringFlag(flags, 'path')
const args: ProjectHostSetupExistingFolderArgs = {
projectId: getRequiredStringFlag(flags, 'project'),
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'],
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
kind: getOptionalRepoKind(flags),
displayName: getOptionalStringFlag(flags, 'display-name')
}
const result = await client.call<{ result: ProjectHostSetupResult }>(
'projectHostSetup.setupExistingFolder',
args
)
printResult(result, json, formatProjectHostSetupResult)
},
'project setup-clone': async ({ flags, client, cwd, json }) => {
const rawDestination = getRequiredStringFlag(flags, 'destination')
const args: ProjectHostSetupCloneArgs = {
projectId: getRequiredStringFlag(flags, 'project'),
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'],
url: getRequiredStringFlag(flags, 'url'),
destination: resolveRepoPathArgument(
rawDestination,
cwd,
client.isRemote,
'Project setup clone'
),
displayName: getOptionalStringFlag(flags, 'display-name')
}
const result = await client.call<{ result: ProjectHostSetupResult }>(
'projectHostSetup.clone',
args
)
printResult(result, json, formatProjectHostSetupResult)
},
'project setup-create': async ({ flags, client, cwd, json }) => {
const path = getOptionalStringFlag(flags, 'path')
const args: ProjectHostSetupCreateArgs = {
projectId: getRequiredStringFlag(flags, 'project'),
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'],
setupId: getOptionalStringFlag(flags, 'setup-id'),
path:
path === undefined
? undefined
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup create'),
kind: getOptionalRepoKind(flags),
displayName: getOptionalStringFlag(flags, 'display-name'),
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
gitUsername: getOptionalStringFlag(flags, 'git-username'),
setupState: getOptionalSetupState(flags),
setupMethod: getOptionalIndependentSetupMethod(flags)
}
const result = await client.call<{ result: ProjectHostSetupCreateResult }>(
'projectHostSetup.create',
args
)
printResult(result, json, formatProjectHostSetupCreateResult)
},
'project setup-update': async ({ flags, client, cwd, json }) => {
const path = getOptionalStringFlag(flags, 'path')
const args: ProjectHostSetupUpdateArgs = {
setupId: getRequiredStringFlag(flags, 'setup'),
updates: {
displayName: getOptionalStringFlag(flags, 'display-name'),
path:
path === undefined
? undefined
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'),
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
gitUsername: getOptionalStringFlag(flags, 'git-username'),
kind: getOptionalRepoKind(flags),
setupState: getOptionalSetupState(flags),
setupMethod: getOptionalSetupMethod(flags)
}
}
const result = await client.call<{ result: ProjectHostSetupUpdateResult }>(
'projectHostSetup.update',
args
)
printResult(result, json, formatProjectHostSetupUpdateResult)
},
'project setup-delete': async ({ flags, client, json }) => {
const result = await client.call<{ result: ProjectHostSetupDeleteResult }>(
'projectHostSetup.delete',
{
setupId: getRequiredStringFlag(flags, 'setup')
}
)
printResult(result, json, formatProjectHostSetupDeleteResult)
}
}
function getOptionalSetupState(
flags: Map<string, string | boolean>
): ProjectHostSetupUpdateArgs['updates']['setupState'] {
const state = getOptionalStringFlag(flags, 'state')
if (state === undefined) {
return undefined
}
if (
state === 'ready' ||
state === 'not-set-up' ||
state === 'setting-up' ||
state === 'error' ||
state === 'unsupported'
) {
return state
}
throw new RuntimeClientError(
'invalid_argument',
'--state must be ready, not-set-up, setting-up, error, or unsupported'
)
}
function getOptionalIndependentSetupMethod(
flags: Map<string, string | boolean>
): ProjectHostSetupCreateArgs['setupMethod'] {
const method = getOptionalStringFlag(flags, 'method')
if (method === undefined) {
return undefined
}
if (method === 'imported-existing-folder' || method === 'cloned' || method === 'provisioned') {
return method
}
throw new RuntimeClientError(
'invalid_argument',
'--method must be imported-existing-folder, cloned, or provisioned'
)
}
function getOptionalSetupMethod(
flags: Map<string, string | boolean>
): ProjectHostSetupUpdateArgs['updates']['setupMethod'] {
const method = getOptionalStringFlag(flags, 'method')
if (method === undefined) {
return undefined
}
if (
method === 'legacy-repo' ||
method === 'imported-existing-folder' ||
method === 'cloned' ||
method === 'provisioned'
) {
return method
}
throw new RuntimeClientError(
'invalid_argument',
'--method must be legacy-repo, imported-existing-folder, cloned, or provisioned'
)
}
+2 -27
View File
@@ -1,33 +1,8 @@
import { resolve as resolvePath } from 'path'
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
'Remote repo add requires --path to be an absolute path on the remote server.'
)
}
return inputPath
}
import { resolveRepoPathArgument } from '../repo-path-arguments'
export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo list': async ({ client, json }) => {
@@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo add': async ({ flags, client, cwd, json }) => {
const repoPath = getRequiredStringFlag(flags, 'path')
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add')
})
printResult(result, json, formatRepoShow)
},
@@ -0,0 +1,44 @@
import type { RuntimeWorktreeCreateResult } from '../../shared/runtime-types'
function getLineageSourceLabel(source: string): string {
switch (source) {
case 'terminal-context':
return 'terminal'
case 'cwd-context':
return 'cwd'
case 'orchestration-context':
return 'orchestration'
case 'env-workspace':
return 'environment'
case 'explicit-cli-flag':
return 'explicit flag'
case 'active-workspace':
return 'active workspace'
default:
return 'manual action'
}
}
export function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
if (json) {
return
}
for (const warning of result.warnings ?? []) {
console.error(`warning: ${warning.message}`)
}
if (result.workspaceLineage) {
const { parentWorkspaceKey, capture } = result.workspaceLineage
console.error(
`parent: ${parentWorkspaceKey} (${capture.confidence} from ${getLineageSourceLabel(capture.source)})`
)
return
}
if (result.lineage) {
const { parentWorktreeId, capture } = result.lineage
console.error(
`parent: ${parentWorktreeId} (${capture.confidence} from ${getLineageSourceLabel(capture.source)})`
)
return
}
console.error('parent: none')
}
@@ -0,0 +1,63 @@
import { parseLinearIssueInput } from '../../shared/linear-links'
import { RuntimeClientError } from '../runtime-client'
type LinearIssueLinkParams = {
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId: string | null
linkedLinearIssueOrganizationUrlKey: string | null
}
export function getOptionalLinearIssueLinkFlag(
flags: Map<string, string | boolean>,
name: string,
options: { allowNull?: boolean } = {}
): LinearIssueLinkParams | undefined {
const value = getPresentStringFlag(flags, name)
if (value === undefined) {
return undefined
}
if (value.trim().toLowerCase() === 'null') {
if (!options.allowNull) {
throw new RuntimeClientError(
'invalid_argument',
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
)
}
return {
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
}
}
const parsed = parseLinearIssueInput(value)
if (!parsed) {
throw new RuntimeClientError(
'invalid_argument',
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
)
}
return {
linkedLinearIssue: parsed.identifier,
// Why: changing a link must not keep a workspace id from a previous issue.
// The org key from URLs is enough for current-resolution to safely rehydrate it.
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
}
}
function getPresentStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
if (!flags.has(name)) {
return undefined
}
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
}
+67 -31
View File
@@ -21,6 +21,14 @@ import {
resolveCurrentWorktreeSelector
} from '../selectors'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { printLineageSummary } from './worktree-lineage-summary'
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
} from '../worktree-project-target'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
type HookWarningResult = {
warning?: string
@@ -46,32 +54,6 @@ function printPreservedBranchWarning(result: PreservedBranchResult, json: boolea
}
}
function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
if (json) {
return
}
for (const warning of result.warnings ?? []) {
console.error(`warning: ${warning.message}`)
}
if (result.lineage) {
const source =
result.lineage.capture.source === 'terminal-context'
? 'terminal'
: result.lineage.capture.source === 'cwd-context'
? 'cwd'
: result.lineage.capture.source === 'orchestration-context'
? 'orchestration'
: result.lineage.capture.source === 'explicit-cli-flag'
? 'explicit flag'
: 'manual action'
console.error(
`parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})`
)
} else {
console.error('parent: none')
}
}
function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
@@ -79,6 +61,18 @@ function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void
'Choose either --parent-worktree or --no-parent, not both.'
)
}
if (flags.has('parent-workspace') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-workspace or --no-parent, not both.'
)
}
if (flags.has('parent-workspace') && flags.has('parent-worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-workspace or --parent-worktree, not both.'
)
}
const parentWorktree = flags.get('parent-worktree')
if (
flags.has('parent-worktree') &&
@@ -86,6 +80,25 @@ function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
}
const parentWorkspace = flags.get('parent-workspace')
if (
flags.has('parent-workspace') &&
(typeof parentWorkspace !== 'string' || parentWorkspace === '')
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-workspace')
}
}
function getEnvParentWorkspace(): string | undefined {
const workspaceId = process.env.ORCA_WORKSPACE_ID
if (typeof workspaceId === 'string' && isWorkspaceKey(workspaceId)) {
return workspaceId
}
const worktreeId = process.env.ORCA_WORKTREE_ID
if (typeof worktreeId === 'string' && worktreeId.length > 0) {
return isWorkspaceKey(worktreeId) ? worktreeId : worktreeWorkspaceKey(worktreeId)
}
return undefined
}
function getPresentStringFlag(
@@ -148,10 +161,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri
return `id:${worktreeId.slice(0, separatorIndex)}`
}
function getCreateRepoSelector(
async function getCreateRepoSelector(
flags: Map<string, string | boolean>,
cwdParentWorktree: string | undefined
): string {
cwdParentWorktree: string | undefined,
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepoSelector) {
return projectRepoSelector
}
const explicitRepo = getPresentStringFlag(flags, 'repo')
if (explicitRepo) {
return explicitRepo
@@ -194,6 +212,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
},
'worktree create': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
@@ -205,11 +224,20 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
cwd,
client
)
const explicitParentWorkspace = getPresentStringFlag(flags, 'parent-workspace')
const startupAgent = getOptionalStartupAgent(flags)
const setupDecision = getOptionalSetupDecision(flags)
const noParent = flags.get('no-parent') === true
const envParentWorkspace =
!noParent && !explicitParentWorkspace && !explicitParentWorktree
? getEnvParentWorkspace()
: undefined
let cwdParentWorktree: string | undefined
if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) {
const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags)
if (
(!explicitParentWorktree && !explicitParentWorkspace && !noParent) ||
needsCwdRepoInference
) {
try {
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
// inside an Orca worktree. Cwd keeps CLI-created children nestable and
@@ -219,17 +247,21 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
cwdParentWorktree = undefined
}
}
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: getCreateRepoSelector(flags, cwdParentWorktree),
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
...linearIssueLink,
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate:
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(startupAgent),
...(setupDecision ? { setupDecision } : {}),
parentWorktree: explicitParentWorktree,
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
...(envParentWorkspace ? { envParentWorkspace } : {}),
...(cwdParentWorktree ? { cwdParentWorktree } : {}),
noParent,
callerTerminalHandle,
@@ -246,10 +278,14 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
},
'worktree set': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', {
allowNull: true
})
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
displayName: getOptionalStringFlag(flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
...linearIssueLink,
comment: getOptionalStringFlag(flags, 'comment'),
workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'),
parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client),
+101 -2
View File
@@ -29,6 +29,15 @@ Automations:
automations run Run an Orca automation now
automations runs List automation run history
Projects:
project list List durable projects known to Orca
project setups List project host setups
project setup-existing-folder Make a project available on a host by importing an existing folder
project setup-clone Make a project available on a host by cloning a repository
project setup-create Create independent project host setup metadata
project setup-update Update project host setup metadata
project setup-delete Remove a project host setup
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -97,6 +106,9 @@ Computer Use:
computer paste-text Paste text through the native clipboard path
computer set-value Set the value of a settable app element
Linear:
linear Read Linear ticket context for agents
Mobile Emulator (iOS Simulator):
emulator list List available/running emulators (Orca-managed + raw serve-sim)
emulator attach <device> Attach/start helper and make active for the worktree
@@ -182,10 +194,10 @@ Common Commands:
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca file open <path> [--worktree <selector>] [--json]
@@ -201,6 +213,13 @@ Common Commands:
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--json]
orca project list [--json]
orca project setups [--project <id>] [--host <host-id>] [--json]
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
orca project setup-delete --setup <setup-id> [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
@@ -211,6 +230,7 @@ Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-workspace <selector> Parent workspace selector such as folder:<id> or worktree:<id>
--parent-worktree <selector> Parent worktree selector; create infers a child of the caller/current worktree by default
--no-parent Force no parent lineage for unrelated worktree creation/update
@@ -274,9 +294,12 @@ Examples:
$ orca repo list
$ orca worktree create --name agent-task --agent codex --prompt "hi"
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
$ orca worktree create --name linear-task --linear-issue STA-335
$ orca worktree show --worktree branch:Jinwoo-H/cli
$ orca worktree current
$ orca worktree set --worktree active --comment "waiting on review"
$ orca worktree set --worktree active --linear-issue null
$ orca worktree ps --limit 10
$ orca file open-changed --mode diff
$ orca file open src/App.tsx
@@ -356,6 +379,54 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
const command = commandPath.join(' ')
if (command === 'linear issue' && flag === 'id') {
return '--id <id> Linear issue key, id, or URL'
}
if (command === 'linear issue' && flag === 'workspace') {
return '--workspace <id> Connected Linear workspace id'
}
if (command === 'linear search' && flag === 'query') {
return '--query <text> Text to search across Linear issues'
}
if (command === 'linear search' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (command.startsWith('linear ') && flag === 'workspace') {
return '--workspace <id> Connected Linear workspace id'
}
if (command.startsWith('linear ') && flag === 'body') {
return '--body <text> Linear comment or issue body'
}
if (command.startsWith('linear ') && flag === 'body-file') {
return '--body-file <path|-> Read Linear body from a file or stdin'
}
if (command.startsWith('linear ') && flag === 'write-id') {
return '--write-id <uuid> Retry id from linear_write_unconfirmed'
}
if (command.startsWith('linear ') && flag === 'to') {
return '--to <state> Exact Linear workflow state name'
}
if (command === 'linear comment add' && flag === 'reply-to') {
return '--reply-to <id> Comment id to reply to'
}
if (command === 'linear attach' && flag === 'url') {
return '--url <url> Absolute http(s) link to attach'
}
if (command === 'linear attach' && flag === 'title') {
return '--title <text> Attachment title'
}
if (command === 'linear create' && flag === 'title') {
return '--title <text> New Linear issue title'
}
if (command === 'linear create' && flag === 'team') {
return '--team <key> Linear team key'
}
if (command === 'linear create' && flag === 'parent') {
return '--parent <id> Parent Linear issue key, id, or URL'
}
if (command === 'linear create' && flag === 'parent-current') {
return '--parent-current Use the current linked issue as parent'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
}
@@ -391,6 +462,8 @@ export function formatFlagHelp(flag: string): string {
interrupt: '--interrupt Send as an interrupt-style input when supported',
id: '--id <id> Identifier for a target item or permission',
issue: '--issue <number|null> Linked GitHub issue number',
'linear-issue':
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
json: '--json Emit machine-readable JSON',
key: '--key <key> Key argument for this command',
limit: '--limit <n> Maximum number of rows to return',
@@ -400,6 +473,8 @@ export function formatFlagHelp(flag: string): string {
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-workspace':
'--parent-workspace <selector> Parent workspace selector such as folder:<id>',
'parent-worktree':
'--parent-worktree <selector> Parent selector; create infers the caller/current worktree by default',
path: '--path <path> Path argument for the command',
@@ -430,6 +505,8 @@ export function formatFlagHelp(flag: string): string {
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
staged: '--staged Open staged source-control changes',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
'source-context':
'--source-context <json|null> Explicit TaskSourceContext for automation task/provider data',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
@@ -459,5 +536,27 @@ export function formatFlagHelp(flag: string): string {
format: '--format <png|jpeg> Screenshot image format'
}
if (flag === 'current') {
return '--current Use the current Orca worktree linked Linear issue'
}
if (flag === 'comments') {
return '--comments Include threaded Linear comments'
}
if (flag === 'children') {
return '--children Include recursive child issues'
}
if (flag === 'depth') {
return '--depth <n> Child issue depth for --children/--full'
}
if (flag === 'attachments') {
return '--attachments Include attachment metadata and URLs'
}
if (flag === 'relations') {
return '--relations Include blocking, related, and duplicate links'
}
if (flag === 'full') {
return '--full Include all supported V1 issue context within caps'
}
return helpByFlag[flag] ?? `--${flag}`
}
+1283 -15
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LinearCreateResult,
LinearIssueContextResult,
LinearProjectListResult,
LinearSearchResult
} from '../shared/linear-agent-access'
import {
formatLinearCreate,
formatLinearIssue,
formatLinearProjectList,
printLinearSearchWarnings
} from './linear-format'
describe('linear-format', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('treats older search results without workspaceErrors as non-partial', () => {
const result = {
issues: [],
meta: {
query: 'auth',
workspaceId: 'all',
limit: 20,
returned: 0,
limitReached: false,
partial: false
}
} as unknown as LinearSearchResult
printLinearSearchWarnings(result)
expect(console.error).not.toHaveBeenCalled()
})
it('includes task fields in issue readback text', () => {
const result = {
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Fix task fields',
url: 'https://linear.app/acme/issue/ENG-123',
state: { name: 'In Progress' },
assignee: { displayName: 'Ada' },
project: null,
labels: [],
priority: 2,
estimate: 5,
dueDate: '2026-06-30'
},
meta: {
sections: {}
}
} as unknown as LinearIssueContextResult
expect(formatLinearIssue(result)).toContain('Priority: high')
expect(formatLinearIssue(result)).toContain('Estimate: 5')
expect(formatLinearIssue(result)).toContain('Due: 2026-06-30')
})
it('formats project rows with names, ids, teams, and workspace', () => {
const result = {
projects: [
{
id: 'project-1',
name: 'Launch',
workspaceName: 'Acme',
teams: [
{ id: 'team-1', name: 'Engineering', key: 'ENG' },
{ id: 'team-2', name: 'Product', key: '' }
]
}
],
meta: { limit: 20, returned: 1, hasMore: false, partial: false, workspaceErrors: [] }
} as unknown as LinearProjectListResult
const output = formatLinearProjectList(result)
expect(output).toContain('Launch')
expect(output).toContain('project-1')
expect(output).toContain('ENG')
expect(output).toContain('Product')
expect(output).toContain('Acme')
})
it('includes the project in create output when present', () => {
const result = {
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Follow up',
url: 'https://linear.app/acme/issue/ENG-123',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: null,
parent: null,
project: { id: 'project-1', name: 'Launch' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '11111111-1111-4111-8111-111111111111',
deduplicated: false
}
} as LinearCreateResult
expect(formatLinearCreate(result)).toBe('Created ENG-123 in Launch: Follow up.')
})
})
+214
View File
@@ -0,0 +1,214 @@
import type {
LinearAttachResult,
LinearCommentAddResult,
LinearCreateResult,
LinearIssueListResult,
LinearIssueContextResult,
LinearIssueTaskUpdateResult,
LinearProjectListResult,
LinearSearchIssueSummary,
LinearSearchResult,
LinearTeamLabelsResult,
LinearTeamListResult,
LinearTeamMembersResult,
LinearTeamStatesResult,
LinearStatusSetResult
} from '../shared/linear-agent-access'
import {
formatLinearProjectListRows,
linearProjectListWarningLines
} from '../shared/linear-project-list-format'
export function formatLinearIssue(result: LinearIssueContextResult): string {
const issue = result.issue
const lines = [
`${issue.identifier} ${issue.title}`,
`URL: ${issue.url}`,
`State: ${issue.state?.name ?? 'unknown'}`,
`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`,
`Project: ${issue.project?.name ?? 'none'}`
]
lines.push(`Priority: ${formatPriority(issue.priority)}`)
lines.push(`Estimate: ${issue.estimate ?? 'none'}`)
if (issue.labels.length > 0) {
lines.push(
`Labels: ${issue.labels
.map((label) => label.name)
.filter(Boolean)
.join(', ')}`
)
}
if (issue.dueDate) {
lines.push(`Due: ${issue.dueDate}`)
}
const sections = result.meta.sections
if (sections.comments) {
lines.push(`Comments: ${sections.comments.returned}`)
}
if (sections.children) {
lines.push(`Children: ${sections.children.returned}`)
}
if (sections.attachments) {
lines.push(`Attachments: ${sections.attachments.returned}`)
}
if (sections.relations) {
lines.push(`Relations: ${sections.relations.returned}`)
}
return lines.join('\n')
}
export function formatLinearSearch(result: LinearSearchResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
}
export function formatLinearTeamList(result: LinearTeamListResult): string {
if (result.teams.length === 0) {
return 'No Linear teams found.'
}
return result.teams
.map((team) => {
const workspace = team.workspace ? ` ${team.workspace.name}` : ''
return `${team.key.padEnd(10)} ${team.name}${workspace}`
})
.join('\n')
}
export function formatLinearTeamMembers(result: LinearTeamMembersResult): string {
if (result.members.length === 0) {
return `No Linear members found for ${result.team.key}.`
}
return result.members
.map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`)
.join('\n')
}
export function formatLinearTeamStates(result: LinearTeamStatesResult): string {
if (result.states.length === 0) {
return `No Linear workflow states found for ${result.team.key}.`
}
return result.states
.map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`)
.join('\n')
}
export function formatLinearTeamLabels(result: LinearTeamLabelsResult): string {
if (result.labels.length === 0) {
return `No Linear labels found for ${result.team.key}.`
}
return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n')
}
export function formatLinearIssueList(result: LinearIssueListResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
}
export function formatLinearProjectList(result: LinearProjectListResult): string {
return formatLinearProjectListRows(result)
}
export function formatLinearStatusSet(result: LinearStatusSetResult): string {
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
}
export function formatLinearCommentAdd(result: LinearCommentAddResult): string {
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
}
export function formatLinearAttach(result: LinearAttachResult): string {
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
}
export function formatLinearCreate(result: LinearCreateResult): string {
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
const suffix = result.meta.deduplicated ? ' (already created)' : ''
return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.`
}
export function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string {
const suffix = result.meta.alreadySet ? ' (already set)' : ''
return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.`
}
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
for (const error of result.meta.includeErrors) {
console.error(`warning: ${error.include} unavailable: ${error.message}`)
}
for (const [name, meta] of Object.entries(result.meta.sections)) {
if (meta?.capReached) {
console.error(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
}
}
}
export function printLinearSearchWarnings(result: LinearSearchResult): void {
if (result.meta.limitReached) {
console.error(`warning: showing first ${result.meta.returned} Linear issues`)
}
for (const error of result.meta.workspaceErrors ?? []) {
console.error(
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
)
}
}
export function printLinearListWarnings(
result: LinearSearchResult | LinearIssueListResult | LinearTeamListResult
): void {
const meta = result.meta
if ('hasMore' in meta && meta.hasMore) {
console.error(`warning: showing first ${meta.returned} Linear issues`)
}
if ('limitReached' in meta && meta.limitReached) {
console.error(`warning: showing first ${meta.returned} Linear issues`)
}
for (const error of meta.workspaceErrors ?? []) {
console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
}
}
export function printLinearProjectListWarnings(result: LinearProjectListResult): void {
for (const warning of linearProjectListWarningLines(result)) {
console.error(warning)
}
}
function formatSearchRow(issue: LinearSearchIssueSummary): string {
const state = issue.state?.name ?? 'unknown'
const assignee = issue.assignee?.displayName ?? 'unassigned'
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
}
function formatPriority(priority: number | null | undefined): string {
if (priority == null || priority === 0) {
return 'none'
}
switch (priority) {
case 1:
return 'urgent'
case 2:
return 'high'
case 3:
return 'medium'
case 4:
return 'low'
default:
return 'none'
}
}
function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string {
if (operation === 'dueDate') {
return 'due date'
}
return operation
}
+274
View File
@@ -0,0 +1,274 @@
import { readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import type {
LinearIssueInclude,
LinearIssueListRequest,
LinearIssueRequest,
LinearIssueTaskUpdateRequest,
LinearWriteTargetRequest
} from '../shared/linear-agent-access'
import {
LINEAR_CHILDREN_MAX_DEPTH,
LINEAR_WRITE_BODY_CAP,
clampLinearIssueDepth
} from '../shared/linear-agent-access'
import { isLinearUuid } from '../shared/linear-uuid'
import {
getOptionalNonNegativeIntegerFlag,
getOptionalStringFlag,
getRepeatedStringFlag,
getRequiredStringFlag,
getRequiredStringFlagAllowingEmpty
} from './flags'
import { RuntimeClientError } from './runtime-client'
const LINEAR_PRIORITY_VALUES = new Map([
['none', 0],
['urgent', 1],
['high', 2],
['medium', 3],
['low', 4]
])
export function buildAssigneeSetRequest(
flags: Map<string, string | boolean>,
cwd: string,
remote: boolean
): LinearIssueTaskUpdateRequest {
const me = flags.get('me') === true
const toId = getOptionalStringFlag(flags, 'to-id')
if (me === Boolean(toId)) {
throw new RuntimeClientError('invalid_argument', 'Pass exactly one of --me or --to-id')
}
return {
...buildWriteTargetRequest(flags, cwd, remote),
operation: 'assignee',
...(me ? { assigneeMe: true } : { assigneeId: toId })
}
}
export function getLinearListFilter(
flags: Map<string, string | boolean>
): LinearIssueListRequest['filter'] {
const filter = getOptionalStringFlag(flags, 'filter') ?? 'assigned'
if (['assigned', 'created', 'all', 'completed', 'open'].includes(filter)) {
return filter as LinearIssueListRequest['filter']
}
throw new RuntimeClientError(
'invalid_argument',
'--filter must be assigned, created, all, completed, or open'
)
}
export function getPriorityFlag(flags: Map<string, string | boolean>, name: string): number {
const value = getRequiredStringFlag(flags, name).toLocaleLowerCase()
const priority = LINEAR_PRIORITY_VALUES.get(value)
if (priority === undefined) {
throw new RuntimeClientError(
'invalid_argument',
`--${name} must be none, low, medium, high, or urgent`
)
}
return priority
}
export function getRequiredNonNegativeIntegerFlag(
flags: Map<string, string | boolean>,
name: string
): number {
const raw = getRequiredStringFlag(flags, name)
const value = Number(raw)
if (!Number.isInteger(value) || value < 0) {
throw new RuntimeClientError('invalid_argument', `--${name} must be a non-negative integer`)
}
return value
}
export function getDueDateFlag(flags: Map<string, string | boolean>, name: string): string {
const value = getRequiredStringFlag(flags, name)
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new RuntimeClientError('invalid_argument', `--${name} must use YYYY-MM-DD`)
}
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year, month - 1, day))
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
throw new RuntimeClientError('invalid_argument', `--${name} must be a real calendar date`)
}
return value
}
export function getRequiredRepeatedStringFlag(
flags: Map<string, string | boolean>,
name: string
): string[] {
const values = getRepeatedStringFlag(flags, name)
if (values.length === 0) {
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
return values
}
export function buildIssueRequest(
flags: Map<string, string | boolean>,
cwd: string,
remote: boolean
): LinearIssueRequest {
const full = flags.get('full') === true
const includes: Record<LinearIssueInclude, boolean> = {
comments: full || flags.get('comments') === true,
children: full || flags.get('children') === true,
attachments: full || flags.get('attachments') === true,
relations: full || flags.get('relations') === true
}
if (flags.has('depth') && !includes.children) {
throw new RuntimeClientError('invalid_argument', '--depth requires --children or --full')
}
const requestedDepth = getOptionalNonNegativeIntegerFlag(flags, 'depth')
if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) {
throw new RuntimeClientError(
'invalid_argument',
`--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}`
)
}
const workspaceId = getOptionalStringFlag(flags, 'workspace')
if (workspaceId === 'all') {
throw new RuntimeClientError(
'linear_invalid_workspace',
'--workspace all is not valid for issue'
)
}
const input = getOptionalStringFlag(flags, 'id')
return {
input,
current: input ? false : flags.get('current') === true,
workspaceId,
include: includes,
depth: clampLinearIssueDepth(requestedDepth),
context: buildLinearCurrentContext(cwd, remote)
}
}
export function buildWriteTargetRequest(
flags: Map<string, string | boolean>,
cwd: string,
remote: boolean
): LinearWriteTargetRequest {
rejectAllWorkspaceForWrite(flags)
const input = getOptionalStringFlag(flags, 'id')
const current = flags.get('current') === true
if (input && current) {
throw new RuntimeClientError('invalid_argument', 'Pass either <id> or --current, not both')
}
if (!input && !current) {
throw new RuntimeClientError('linear_issue_required', 'Pass a Linear issue id or --current')
}
return {
input,
current,
workspaceId: getOptionalStringFlag(flags, 'workspace'),
context: buildLinearCurrentContext(cwd, remote)
}
}
export function buildLinearCurrentContext(
cwd: string,
remote: boolean
): LinearIssueRequest['context'] {
return {
remote,
...(remote ? {} : { cwd }),
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
...(process.env.ORCA_TERMINAL_HANDLE
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
: {})
}
}
export function rejectAllWorkspaceForWrite(flags: Map<string, string | boolean>): void {
if (getOptionalStringFlag(flags, 'workspace') === 'all') {
throw new RuntimeClientError(
'linear_invalid_workspace',
'--workspace all is not valid for Linear writes'
)
}
}
export function getOptionalWriteId(flags: Map<string, string | boolean>): string | undefined {
if (!flags.has('write-id')) {
return undefined
}
const writeId = getRequiredStringFlag(flags, 'write-id')
if (!isLinearUuid(writeId)) {
throw new RuntimeClientError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
export function getHttpUrlFlag(flags: Map<string, string | boolean>, name: string): string {
const value = getRequiredStringFlag(flags, name)
try {
const parsed = new URL(value)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return value
}
} catch {
// Fall through to the stable Linear error below.
}
throw new RuntimeClientError('linear_invalid_url', '--url must be an absolute http(s) URL')
}
export function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: true }
): Promise<string>
export function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: false }
): Promise<string | undefined>
export async function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: boolean }
): Promise<string | undefined> {
const hasBody = flags.has('body')
const hasBodyFile = flags.has('body-file')
if (hasBody && hasBodyFile) {
throw new RuntimeClientError('invalid_argument', 'Use either --body or --body-file, not both')
}
if (!hasBody && !hasBodyFile) {
if (options.required) {
throw new RuntimeClientError('invalid_argument', 'Missing --body or --body-file')
}
return undefined
}
const body = hasBody
? getRequiredStringFlagAllowingEmpty(flags, 'body')
: await readLinearBodyFile(getRequiredStringFlag(flags, 'body-file'), cwd)
if (body.length > LINEAR_WRITE_BODY_CAP) {
throw new RuntimeClientError(
'linear_body_too_large',
`Linear body must be at most ${LINEAR_WRITE_BODY_CAP} characters`
)
}
return body
}
async function readLinearBodyFile(path: string, cwd: string): Promise<string> {
if (path !== '-') {
return await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8')
}
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY')
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
}
+80
View File
@@ -0,0 +1,80 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteResult,
ProjectHostSetupResult,
ProjectHostSetupUpdateResult
} from '../shared/types'
export function formatProjectList(result: { projects: Project[] }): string {
if (result.projects.length === 0) {
return 'No projects found.'
}
return result.projects
.map((project) => {
const identity = project.providerIdentity
? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}`
: 'no-provider'
return `${project.id} ${project.displayName} ${identity}`
})
.join('\n')
}
export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string {
if (result.setups.length === 0) {
return 'No project host setups found.'
}
return result.setups
.map(
(setup) =>
`${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}`
)
.join('\n')
}
export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string {
const { project, setup, repo } = result.result
return formatProjectHostSetupResultFields(project, setup, repo.id)
}
export function formatProjectHostSetupCreateResult(result: {
result: ProjectHostSetupCreateResult
}): string {
const { project, setup } = result.result
return formatProjectHostSetupResultFields(project, setup, undefined)
}
export function formatProjectHostSetupUpdateResult(result: {
result: ProjectHostSetupUpdateResult
}): string {
const { project, setup, repo } = result.result
return formatProjectHostSetupResultFields(project, setup, repo?.id)
}
export function formatProjectHostSetupDeleteResult(result: {
result: ProjectHostSetupDeleteResult
}): string {
const { project, setup, repo } = result.result
return [
`deleted: ${setup.id}`,
formatProjectHostSetupResultFields(project, setup, repo?.id)
].join('\n')
}
function formatProjectHostSetupResultFields(
project: Project,
setup: ProjectHostSetup,
repoId: string | undefined
): string {
return [
`projectId: ${project.id}`,
`project: ${project.displayName}`,
`setupId: ${setup.id}`,
`hostId: ${setup.hostId}`,
`path: ${setup.path}`,
`state: ${setup.setupState}`,
`method: ${setup.setupMethod}`,
`repoId: ${repoId ?? 'none'}`
].join('\n')
}
+31
View File
@@ -0,0 +1,31 @@
import { resolve as resolvePath } from 'path'
import { RuntimeClientError } from './runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
export function resolveRepoPathArgument(
inputPath: string,
cwd: string,
isRemote: boolean,
remotePathSubject = 'Remote repo path'
): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
`${remotePathSubject} requires --path to be an absolute path on the remote server.`
)
}
return inputPath
}
+76
View File
@@ -0,0 +1,76 @@
import { mkdtempSync, writeFileSync } from 'fs'
import { createServer, type Socket } from 'net'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap'
import { RuntimeClient } from './client'
const servers = new Set<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
afterEach(async () => {
for (const socket of sockets) {
socket.destroy()
}
sockets.clear()
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
servers.clear()
})
// Why: legacy runtime metadata compatibility only applies to local Unix socket
// metadata; Windows uses named pipes and cannot run this fixture directly.
describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
it('uses the legacy singular runtime transport when reporting status', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: {
runtimeId: 'runtime-legacy',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0
},
_meta: { runtimeId: 'runtime-legacy' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeFileSync(
getRuntimeMetadataPath(userDataPath),
JSON.stringify({
runtimeId: 'runtime-legacy',
pid: process.pid,
transport: { kind: 'unix', endpoint },
authToken: 'token',
startedAt: Date.now()
})
)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime).toMatchObject({
reachable: true,
runtimeId: 'runtime-legacy',
state: 'ready'
})
})
})
+3 -1
View File
@@ -1,4 +1,5 @@
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { findTransport } from '../../shared/runtime-bootstrap'
import { tryReadMetadata } from './metadata'
import { sendRequest } from './transport'
import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
@@ -7,7 +8,8 @@ export async function getCliStatus(
userDataPath: string
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const metadata = tryReadMetadata(userDataPath)
if (!metadata?.transports?.length || !metadata.authToken) {
const transport = metadata ? findTransport(metadata, 'unix', 'named-pipe') : null
if (!transport || !metadata?.authToken) {
return buildCliStatusResponse({
app: {
running: false,
+13 -2
View File
@@ -1,7 +1,16 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch']
const AUTOMATION_TARGET_FLAGS = [
'repo',
'workspace',
'project',
'host',
'project-host-setup',
'source-context',
'workspace-mode',
'base-branch'
]
const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone']
const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout']
const AUTOMATION_STATE_FLAGS = [
@@ -32,7 +41,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
path: ['automations', 'create'],
summary: 'Create a scheduled Orca automation',
usage:
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--json]',
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>|--project <id> [--host <id>]|--project-host-setup <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'name',
@@ -46,6 +55,8 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
'Use --project with --host, or --project-host-setup, to run on a specific project host setup.',
'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.',
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.',
'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.',
'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.'
+19 -4
View File
@@ -102,17 +102,22 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
'project',
'host',
'project-host-setup',
'name',
'agent',
'prompt',
'base-branch',
'issue',
'linear-issue',
'comment',
'setup',
'parent-workspace',
'parent-worktree',
'no-parent',
'run-hooks',
@@ -121,7 +126,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
notes: [
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.',
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
'For related work, use the inferred parent or pass --parent-workspace folder:<id> or worktree:<id>, or --parent-worktree active, to make the relationship explicit.',
'Use --no-parent when the new worktree should be independent of the current workspace.',
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
@@ -132,7 +138,10 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
examples: [
'orca worktree create --name agent-task --agent codex --prompt "hi" --json',
'orca worktree create --repo id:<repoId> --name related-task --json',
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
'orca worktree create --repo id:<repoId> --name folder-child --parent-workspace folder:<folderWorkspaceId> --json',
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
'orca worktree create --repo id:<repoId> --name independent-task --no-parent --json'
]
@@ -141,19 +150,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'set'],
summary: 'Update Orca metadata for a worktree',
usage:
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'worktree',
'display-name',
'issue',
'linear-issue',
'comment',
'workspace-status',
'parent-worktree',
'no-parent'
],
notes: [
'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.'
'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.',
'Pass --linear-issue null to clear the Linear issue link.'
],
examples: [
'orca worktree set --worktree active --linear-issue STA-335 --json',
'orca worktree set --worktree active --linear-issue null --json'
]
},
{
+4
View File
@@ -4,15 +4,18 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { AUTOMATION_COMMAND_SPECS } from './automations'
import { CORE_COMMAND_SPECS } from './core'
import { FILE_COMMAND_SPECS } from './file'
import { PROJECT_COMMAND_SPECS } from './project'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
import { EMULATOR_COMMAND_SPECS } from './emulator'
import { LINEAR_COMMAND_SPECS } from './linear'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...PROJECT_COMMAND_SPECS,
...FILE_COMMAND_SPECS,
...AUTOMATION_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
@@ -22,5 +25,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...AGENT_HOOK_COMMAND_SPECS,
...DIAGNOSTICS_COMMAND_SPECS,
...ENVIRONMENT_COMMAND_SPECS,
...LINEAR_COMMAND_SPECS,
...EMULATOR_COMMAND_SPECS
]
+252
View File
@@ -0,0 +1,252 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
{
path: ['linear', 'issue'],
summary: 'Read Linear issue context for agents',
usage:
'orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'current',
'comments',
'children',
'depth',
'attachments',
'relations',
'full',
'workspace',
'id'
],
positionalArgs: ['id'],
examples: [
'orca linear issue ENG-123',
'orca linear issue --current --comments',
'orca linear issue https://linear.app/acme/issue/ENG-123 --full --json'
]
},
{
path: ['linear', 'search'],
summary: 'Search connected Linear workspaces',
usage: 'orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
positionalArgs: ['query'],
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --json']
},
{
path: ['linear', 'team', 'list'],
summary: 'List connected Linear teams',
usage: 'orca linear team list [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'workspace'],
examples: ['orca linear team list --workspace all --json']
},
{
path: ['linear', 'team', 'members'],
summary: 'List Linear team members',
usage: 'orca linear team members --team <key|id> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
examples: ['orca linear team members --team ENG --json']
},
{
path: ['linear', 'team', 'states'],
summary: 'List Linear team workflow states',
usage: 'orca linear team states --team <key|id> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
examples: ['orca linear team states --team ENG --json']
},
{
path: ['linear', 'team', 'labels'],
summary: 'List Linear team labels',
usage: 'orca linear team labels --team <key|id> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
examples: ['orca linear team labels --team ENG --json']
},
{
path: ['linear', 'project', 'list'],
summary: 'List connected Linear projects',
usage:
'orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'query', 'limit', 'workspace'],
examples: [
'orca linear project list --query launch --json',
'orca linear project list --workspace all --json'
]
},
{
path: ['linear', 'list'],
summary: 'List Linear issues for task triage',
usage:
'orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'filter', 'team', 'limit', 'workspace'],
examples: ['orca linear list --filter assigned --limit 10 --json']
},
{
path: ['linear', 'status', 'set'],
summary: 'Set a Linear issue status',
usage: 'orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
positionalArgs: ['id'],
examples: [
'orca linear status set ENG-123 --to "In Review"',
'orca linear status set --current --to Done --json'
]
},
{
path: ['linear', 'assignee', 'set'],
summary: 'Assign a Linear issue',
usage:
'orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'me', 'to-id', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear assignee set --current --me --json']
},
{
path: ['linear', 'assignee', 'clear'],
summary: 'Clear a Linear issue assignee',
usage: 'orca linear assignee clear [<id>] [--current] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear assignee clear ENG-123 --json']
},
{
path: ['linear', 'priority', 'set'],
summary: 'Set a Linear issue priority',
usage:
'orca linear priority set [<id>] [--current] --to none|low|medium|high|urgent [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear priority set --current --to high --json']
},
{
path: ['linear', 'priority', 'clear'],
summary: 'Clear a Linear issue priority',
usage: 'orca linear priority clear [<id>] [--current] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear priority clear ENG-123 --json']
},
{
path: ['linear', 'estimate', 'set'],
summary: 'Set a Linear issue estimate',
usage: 'orca linear estimate set [<id>] [--current] --to <number> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear estimate set --current --to 3 --json']
},
{
path: ['linear', 'estimate', 'clear'],
summary: 'Clear a Linear issue estimate',
usage: 'orca linear estimate clear [<id>] [--current] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear estimate clear ENG-123 --json']
},
{
path: ['linear', 'due-date', 'set'],
summary: 'Set a Linear issue due date',
usage:
'orca linear due-date set [<id>] [--current] --to <yyyy-mm-dd> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear due-date set --current --to 2026-06-30 --json']
},
{
path: ['linear', 'due-date', 'clear'],
summary: 'Clear a Linear issue due date',
usage: 'orca linear due-date clear [<id>] [--current] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear due-date clear ENG-123 --json']
},
{
path: ['linear', 'label', 'add'],
summary: 'Add labels to a Linear issue',
usage:
'orca linear label add [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear label add --current --label Bug --json']
},
{
path: ['linear', 'label', 'remove'],
summary: 'Remove labels from a Linear issue',
usage:
'orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear label remove --current --label Bug --json']
},
{
path: ['linear', 'label', 'set'],
summary: 'Replace labels on a Linear issue',
usage:
'orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
positionalArgs: ['id'],
examples: ['orca linear label set ENG-123 --label Bug --json']
},
{
path: ['linear', 'comment', 'add'],
summary: 'Add a comment to a Linear issue',
usage:
'orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'current',
'body',
'body-file',
'reply-to',
'write-id',
'workspace',
'id'
],
positionalArgs: ['id'],
examples: [
'orca linear comment add ENG-123 --body "Implementation is ready for review."',
'orca linear comment add --current --body-file - --json'
],
notes: ['Use --body-file - to read multiline comment bodies from stdin.']
},
{
path: ['linear', 'attach'],
summary: 'Attach a link to a Linear issue',
usage:
'orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'url', 'title', 'write-id', 'workspace', 'id'],
positionalArgs: ['id'],
examples: [
'orca linear attach ENG-123 --url https://example.com/review/123 --title "PR/MR link"',
'orca linear attach --current --url https://example.com/review/123 --json'
]
},
{
path: ['linear', 'create'],
summary: 'Create a Linear issue',
usage:
'orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'title',
'body',
'body-file',
'team',
'project',
'state',
'assignee',
'priority',
'estimate',
'due-date',
'label',
'parent',
'parent-current',
'write-id',
'workspace'
],
examples: [
'orca linear create --title "Investigate flaky login" --team ENG --project "Launch"',
'orca linear create --title "Follow-up bug" --parent-current --body-file - --json'
],
notes: ['Use --body-file - to read multiline issue bodies from stdin.']
}
]
+1
View File
@@ -25,6 +25,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
],
notes: [
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
]
},
+113
View File
@@ -0,0 +1,113 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['project', 'list'],
summary: 'List durable projects known to Orca',
usage: 'orca project list [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca project list', 'orca project list --json']
},
{
path: ['project', 'setups'],
summary: 'List project host setups',
usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'],
notes: ['A setup means a project is available on a host at a concrete filesystem path.'],
examples: [
'orca project setups',
'orca project setups --project github:stablyai/orca',
'orca project setups --host local'
]
},
{
path: ['project', 'setup-existing-folder'],
summary: 'Make a project available on a host by importing an existing folder',
usage:
'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
notes: ['For remote runtimes, --path must be an absolute path on the remote server.'],
examples: [
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'
]
},
{
path: ['project', 'setup-clone'],
summary: 'Make a project available on a host by cloning a repository',
usage:
'orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'],
notes: [
'For remote runtimes, --destination must be an absolute parent directory on the remote server.',
'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.'
],
examples: [
'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src',
'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json'
]
},
{
path: ['project', 'setup-create'],
summary: 'Create independent project host setup metadata',
usage:
'orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'project',
'host',
'setup-id',
'path',
'kind',
'display-name',
'worktree-base-path',
'git-username',
'state',
'method'
],
notes: [
'Creates setup metadata without registering a repo compatibility record.',
'Use setup-existing-folder when Orca should import and manage an actual checkout path now.'
],
examples: [
'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json'
]
},
{
path: ['project', 'setup-update'],
summary: 'Update project host setup metadata',
usage:
'orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'setup',
'display-name',
'path',
'worktree-base-path',
'git-username',
'kind',
'state',
'method'
],
notes: [
'Repo-backed setups mirror safe fields onto the repo record.',
'Path and availability state changes are only supported for independent setup records.'
],
examples: [
'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"',
'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json'
]
},
{
path: ['project', 'setup-delete'],
summary: 'Remove a project host setup',
usage: 'orca project setup-delete --setup <setup-id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
notes: [
'Independent setups are removed directly.',
'Repo-backed setups remove the registered repo compatibility record.'
],
examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json']
}
]
+13 -1
View File
@@ -1,4 +1,5 @@
import type { Automation, AutomationRun } from '../shared/automations-types'
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck'
import { formatAutomationSchedule } from '../shared/automation-schedules'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
@@ -178,6 +179,17 @@ export function formatAutomationList(result: { automations: Automation[] }): str
export function formatAutomationShow(result: { automation: Automation }): string {
const automation = result.automation
const runContext = automation.runContext ?? null
const projectLines = runContext
? [
`runProjectId: ${runContext.projectId}`,
`runHostId: ${runContext.hostId}`,
`projectHostSetupId: ${runContext.projectHostSetupId}`,
`runRepoId: ${runContext.repoId}`,
`runPath: ${runContext.path}`,
`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`
]
: [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`]
return [
`id: ${automation.id}`,
`name: ${automation.name}`,
@@ -193,7 +205,7 @@ export function formatAutomationShow(result: { automation: Automation }): string
: 'none'
}`,
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
`projectId: ${automation.projectId}`,
...projectLines,
`workspaceMode: ${automation.workspaceMode}`,
`workspaceId: ${automation.workspaceId ?? 'null'}`,
`baseBranch: ${automation.baseBranch ?? 'null'}`,
+85
View File
@@ -0,0 +1,85 @@
import type { ProjectHostSetup } from '../shared/types'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
export type ProjectCreateTarget = {
repoSelector: string
setup: ProjectHostSetup
}
function getPresentStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
if (!flags.has(name)) {
return undefined
}
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
}
export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean {
return flags.has('project') || flags.has('host') || flags.has('project-host-setup')
}
export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void {
const hasProjectTarget = hasWorkspaceProjectTarget(flags)
if (flags.has('repo') && hasProjectTarget) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --repo or project target flags, not both.'
)
}
if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) {
throw new RuntimeClientError(
'invalid_argument',
'--host requires --project unless --project-host-setup is provided.'
)
}
}
export async function resolveProjectCreateRepoSelector(
flags: Map<string, string | boolean>,
client: RuntimeClient
): Promise<string | undefined> {
return (await resolveProjectCreateTarget(flags, client))?.repoSelector
}
export async function resolveProjectCreateTarget(
flags: Map<string, string | boolean>,
client: RuntimeClient
): Promise<ProjectCreateTarget | undefined> {
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
const projectId = getPresentStringFlag(flags, 'project')
const hostId = getPresentStringFlag(flags, 'host')
if (!projectHostSetupId && !projectId && !hostId) {
return undefined
}
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
const setup = result.result.setups.find((candidate) => {
if (candidate.setupState !== 'ready') {
return false
}
if (projectHostSetupId) {
return candidate.id === projectHostSetupId
}
return (
candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId)
)
})
if (!setup) {
throw new RuntimeClientError(
'invalid_argument',
projectHostSetupId
? `Project host setup is not ready or was not found: ${projectHostSetupId}`
: `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}`
)
}
return {
repoSelector: `id:${setup.repoId}`,
setup
}
}
@@ -0,0 +1,100 @@
import type { vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
import type {
FirstWorkBranchRenameDeps,
FirstWorkBranchRenameEvent
} from './first-work-branch-rename'
export const REPO_ID = 'repo1'
export const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
const FOLDER_WORKSPACE_ID = 'folder-workspace-1'
export const FOLDER_WORKTREE_ID = `folder:${FOLDER_WORKSPACE_ID}`
const TAB_ID = 'tab-1'
const PANE_KEY = `${TAB_ID}:leaf-1`
export const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'")
export function gitResponder(opts: {
currentBranch: string
hasUpstream: boolean
existingRefs?: string[]
}) {
return async (args: string[]) => {
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
if (opts.hasUpstream) {
return { stdout: 'origin/x\n', stderr: '' }
}
throw noUpstreamError
}
if (args[0] === 'rev-parse') {
return { stdout: `${opts.currentBranch}\n`, stderr: '' }
}
if (args[0] === 'show-ref') {
const ref = args.at(-1) ?? ''
if ((opts.existingRefs ?? []).includes(ref)) {
return { stdout: '', stderr: '' }
}
throw new Error('not found')
}
if (args[0] === 'branch' && args[1] === '-m') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
}
}
type VitestMockFactory = typeof vi.fn
type VitestMock = ReturnType<VitestMockFactory>
export function makeBranchRenameDeps(
mockFn: VitestMockFactory,
overrides: Partial<FirstWorkBranchRenameDeps> = {}
): {
deps: FirstWorkBranchRenameDeps
onRenamed: VitestMock
setDisplayName: VitestMock
renameWorktreeFolder: VitestMock
setRenameError: VitestMock
} {
const onRenamed = mockFn()
const setDisplayName = mockFn()
const renameWorktreeFolder = mockFn(async () => false)
const setRenameError = mockFn()
const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings
const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo
return {
onRenamed,
setDisplayName,
renameWorktreeFolder,
setRenameError,
deps: {
getSettings: () => settings,
getRepo: () => repo,
getAgentEnvResolvers: () => undefined,
getCurrentDisplayName: () => 'Nautilus-8',
canRenameOrcaCreatedBranch: () => true,
setDisplayName,
renameWorktreeFolder,
setRenameError,
resolveWorktreeIdForTab: () => WORKTREE_ID,
onRenamed,
...overrides
}
}
}
export function workingEvent(
overrides: Partial<FirstWorkBranchRenameEvent> = {}
): FirstWorkBranchRenameEvent {
return {
paneKey: PANE_KEY,
tabId: TAB_ID,
worktreeId: undefined,
state: 'working',
prompt: 'Fix the auth bug',
assistantMessage: undefined,
isReplay: false,
...overrides
}
}
@@ -1,5 +1,3 @@
/* eslint-disable max-lines -- Why: the orchestrator tests cover local, SSH,
retry, and post-generation race guards; splitting would duplicate mocks. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
@@ -12,7 +10,8 @@ const {
generateBranchNameMock,
resolveTextGenerationParamsMock,
prepareLocalEnvMock,
computeBranchNameMock
computeBranchNameMock,
getConfiguredBranchPrefixMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getGitUsernameMock: vi.fn(() => 'you'),
@@ -21,7 +20,9 @@ const {
generateBranchNameMock: vi.fn(),
resolveTextGenerationParamsMock: vi.fn(),
prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })),
computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`)
computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`),
// Mirror computeBranchNameMock's `you/` strategy so prefix stripping is realistic.
getConfiguredBranchPrefixMock: vi.fn((_settings: unknown, username: string | null) => username)
}))
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
@@ -35,95 +36,29 @@ vi.mock('../text-generation/commit-message-text-generation', () => ({
vi.mock('../text-generation/commit-message-agent-environment', () => ({
prepareLocalCommitMessageAgentEnv: prepareLocalEnvMock
}))
vi.mock('../ipc/worktree-logic', () => ({ computeBranchName: computeBranchNameMock }))
vi.mock('../ipc/worktree-logic', () => ({
computeBranchName: computeBranchNameMock,
getConfiguredBranchPrefix: getConfiguredBranchPrefixMock
}))
import {
FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT,
maybeAutoRenameBranchOnFirstWork,
resetFirstWorkBranchRenameState,
type FirstWorkBranchRenameDeps,
type FirstWorkBranchRenameEvent
type FirstWorkBranchRenameDeps
} from './first-work-branch-rename'
import {
FOLDER_WORKTREE_ID,
REPO_ID,
WORKTREE_ID,
gitResponder,
makeBranchRenameDeps,
noUpstreamError,
workingEvent
} from './first-work-branch-rename-test-harness'
const REPO_ID = 'repo1'
const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
const TAB_ID = 'tab-1'
const PANE_KEY = `${TAB_ID}:leaf-1`
const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'")
function gitResponder(opts: {
currentBranch: string
hasUpstream: boolean
existingRefs?: string[]
}) {
return async (args: string[]) => {
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
if (opts.hasUpstream) {
return { stdout: 'origin/x\n', stderr: '' }
}
throw noUpstreamError
}
if (args[0] === 'rev-parse') {
return { stdout: `${opts.currentBranch}\n`, stderr: '' }
}
if (args[0] === 'show-ref') {
const ref = args.at(-1) ?? ''
if ((opts.existingRefs ?? []).includes(ref)) {
return { stdout: '', stderr: '' }
}
throw new Error('not found')
}
if (args[0] === 'branch' && args[1] === '-m') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
}
}
function makeDeps(overrides: Partial<FirstWorkBranchRenameDeps> = {}): {
deps: FirstWorkBranchRenameDeps
onRenamed: ReturnType<typeof vi.fn>
setDisplayName: ReturnType<typeof vi.fn>
setRenameError: ReturnType<typeof vi.fn>
} {
const onRenamed = vi.fn()
const setDisplayName = vi.fn()
const setRenameError = vi.fn()
const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings
const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo
return {
onRenamed,
setDisplayName,
setRenameError,
deps: {
getSettings: () => settings,
getRepo: () => repo,
getAgentEnvResolvers: () => undefined,
getCurrentDisplayName: () => 'Nautilus-8',
canRenameOrcaCreatedBranch: () => true,
setDisplayName,
setRenameError,
resolveWorktreeIdForTab: () => WORKTREE_ID,
onRenamed,
...overrides
}
}
}
function workingEvent(
overrides: Partial<FirstWorkBranchRenameEvent> = {}
): FirstWorkBranchRenameEvent {
return {
paneKey: PANE_KEY,
tabId: TAB_ID,
worktreeId: undefined,
state: 'working',
prompt: 'Fix the auth bug',
assistantMessage: undefined,
isReplay: false,
...overrides
}
function makeDeps(overrides: Partial<FirstWorkBranchRenameDeps> = {}) {
return makeBranchRenameDeps(vi.fn, overrides)
}
describe('maybeAutoRenameBranchOnFirstWork', () => {
@@ -162,6 +97,64 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('asks to align the on-disk folder with the generated slug after renaming', async () => {
const { deps, renameWorktreeFolder } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth')
})
it('skips the redundant branch-rename notify when the folder rename succeeded', async () => {
// The folder rename already pushed a worktrees:changed carrying the id mapping,
// so onRenamed would only trigger a second, redundant renderer re-list.
const { deps, onRenamed } = makeDeps({ renameWorktreeFolder: vi.fn(async () => true) })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(onRenamed).not.toHaveBeenCalled()
})
it('survives a folder-rename failure without undoing the branch/display rename', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps({
renameWorktreeFolder: vi.fn(async () => {
throw new Error('git worktree move failed')
})
})
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth')
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('strips a prefix the model leaked into the slug from both branch and display name', async () => {
// Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the
// sanitizer folds to `you-worktree-spinner`; without stripping it would
// double-prefix the branch (`you/you-...`) and show "You worktree spinner".
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' })
const { deps, setDisplayName } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/worktree-spinner'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Worktree spinner')
})
it('skips the rename when the model echoes only the configured prefix', async () => {
// The model emitted just `you` (the prefix); stripping leaves an empty slug,
// so renaming would re-add the prefix and double it to `you/you`.
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you' })
const { deps, onRenamed, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
['branch', '-m', expect.anything()],
expect.anything()
)
expect(onRenamed).not.toHaveBeenCalled()
// Benign terminal state: clear any stale badge, never raise a new one.
expect(setRenameError).not.toHaveBeenCalledWith(WORKTREE_ID, expect.any(String))
})
it('leaves a user-customized display name untouched while still renaming the branch', async () => {
const { deps, setDisplayName } = makeDeps({ getCurrentDisplayName: () => 'My cool feature' })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
@@ -192,6 +185,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('renames a pending folder workspace title without touching git', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps({
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
getFolderWorkspacePath: () => '/workspace/platform',
isPendingFirstAgentMessageRename: () => true,
getCurrentDisplayName: () => 'Platform workspace'
})
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith(
expect.anything(),
'local',
'branchName',
null
)
expect(setDisplayName).toHaveBeenCalledWith(FOLDER_WORKTREE_ID, 'Fix auth')
expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID)
})
it('does not rename folder workspace titles without the pending marker', async () => {
const { deps, setDisplayName } = makeDeps({
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
getFolderWorkspacePath: () => '/workspace/platform',
isPendingFirstAgentMessageRename: () => false
})
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(setDisplayName).not.toHaveBeenCalled()
})
it('ignores replayed events and non-working states', async () => {
const { deps } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps)
@@ -339,7 +367,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).not.toHaveBeenCalled()
})
it('suffixes when the generated branch name already exists', async () => {
it('suffixes the branch, display name, and folder together on collision', async () => {
gitExecFileAsyncMock.mockImplementation(
gitResponder({
currentBranch: 'you/Nautilus',
@@ -347,12 +375,16 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
existingRefs: ['refs/heads/you/fix-auth']
})
)
const { deps } = makeDeps()
const { deps, setDisplayName, renameWorktreeFolder } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth-2'],
expect.objectContaining({ cwd: '/repo/wt' })
)
// Display name and folder must follow the resolved (suffixed) leaf, not the
// pre-suffix slug — otherwise they diverge from the branch.
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth 2')
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth-2')
})
it('does not rename when the branch changes while generation is running', async () => {
+117 -32
View File
@@ -5,18 +5,19 @@
// summarize the prompt via the configured agent, and rename.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
humanizeBranchSlug,
isAutoGeneratedCreatureBranchName
isAutoGeneratedCreatureBranchName,
stripConfiguredBranchPrefix
} from '../../shared/branch-name-from-work'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName } from '../ipc/worktree-logic'
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
import { getGitUsername } from '../git/repo'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import {
branchHasUpstream,
renameCurrentBranch,
@@ -25,13 +26,10 @@ import {
} from '../git/branch-rename'
import {
generateBranchNameFromContext,
resolveTextGenerationParams,
type CommitMessageGenerationTarget
resolveTextGenerationParams
} from '../text-generation/commit-message-text-generation'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
} from '../text-generation/commit-message-agent-environment'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { resolveGenerationTarget } from './first-work-generation-target'
export type FirstWorkBranchRenameEvent = {
paneKey: string
@@ -50,10 +48,16 @@ export type FirstWorkBranchRenameDeps = {
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
/** Current sidebar display name for the worktree, if one is stored. */
getCurrentDisplayName: (worktreeId: string) => string | undefined
/** Current workspace path for non-git folder workspaces. */
getFolderWorkspacePath?: (worktreeId: string) => string | undefined
/** True while a workspace title is waiting for the first agent message. */
isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean
/** True only for Orca-created worktrees whose branch Orca is allowed to rename. */
canRenameOrcaCreatedBranch: (worktreeId: string) => boolean
/** Persist a new sidebar display name for the worktree. */
setDisplayName: (worktreeId: string, displayName: string) => void
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise<boolean>
/** Record (or clear with null) a user-facing auto-rename generation failure
* so the sidebar can show a "rename failed" badge instead of silent retries. */
setRenameError: (worktreeId: string, error: string | null) => void
@@ -164,6 +168,18 @@ async function runAutoRename(
return false
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return runFolderWorkspaceTitleAutoRename(
worktreeId,
prompt,
assistantMessage,
deps,
stop,
retry
)
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
if (!repo || !parsed) {
@@ -241,26 +257,42 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: getGitUsername(repo.path) || null
// The model is told not to add a prefix, but sometimes echoes the configured
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
// leak into the display name.
const slug = stripConfiguredBranchPrefix(
generated.slug,
getConfiguredBranchPrefix(settings, username)
)
// Prefix-only model output strips to empty; renaming with it would just
// re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip.
if (!slug) {
return stop('model produced only the configured prefix', true)
}
const newBranch = await resolveUniqueBranchName(
exec,
generated.slug,
slug,
(slugLeaf) => computeBranchName(slugLeaf, settings, username),
currentBranch
)
if (!newBranch || newBranch === currentBranch) {
// Generation succeeded but yielded no distinct name — terminal and benign,
// so clear any stale failure badge a prior transient attempt left behind.
return stop(`no distinct unique branch name for slug "${generated.slug}"`, true)
return stop(`no distinct unique branch name for slug "${slug}"`, true)
}
await (provider
? provider.renameCurrentBranch(worktreePath, newBranch)
: renameCurrentBranch(exec, newBranch))
// resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so
// derive the sidebar name and on-disk folder from the *resolved* branch leaf,
// not the pre-suffix slug, to keep branch, display, and folder aligned.
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
// Keep the sidebar name in sync with the branch — but only when it is still
// the auto-generated creature name, so a name the user typed is left alone.
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
const newDisplayName = humanizeBranchSlug(generated.slug)
const newDisplayName = humanizeBranchSlug(newBranchLeaf)
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
if (updateDisplay) {
deps.setDisplayName(worktreeId, newDisplayName)
@@ -268,32 +300,85 @@ async function runAutoRename(
// A successful rename clears any stale generation-failure surfaced earlier.
deps.setRenameError(worktreeId, null)
deps.onRenamed(repo.id)
// Align the on-disk folder with the new branch leaf. Best-effort and local-only:
// a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is
// and must never undo the branch/display rename that already landed. Runs after
// setDisplayName so the new display name rides along into the migrated identity.
let folderRenamed = false
if (deps.renameWorktreeFolder) {
try {
folderRenamed = await deps.renameWorktreeFolder(worktreeId, newBranchLeaf)
} catch (error) {
console.warn('[auto-branch-rename] folder rename failed:', error)
}
}
// A successful folder rename already invalidated caches and pushed a
// worktrees:changed carrying the id mapping; a second onRenamed would only
// trigger a redundant renderer re-list. Otherwise notify for the branch rename.
if (!folderRenamed) {
deps.onRenamed(repo.id)
}
const displayLog = updateDisplay
? `display "${currentDisplayName ?? ''}" -> "${newDisplayName}"`
: `display kept ("${currentDisplayName}")`
console.info(`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}`)
const folderLog = folderRenamed ? '; folder renamed' : ''
console.info(
`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}${folderLog}`
)
return true
}
async function resolveGenerationTarget(
worktreePath: string,
agentId: string,
provider: SshGitProvider | null,
deps: FirstWorkBranchRenameDeps
): Promise<CommitMessageGenerationTarget | null> {
if (provider) {
return {
kind: 'remote',
cwd: worktreePath,
execute: (plan, cwd, timeoutMs, operation) =>
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
async function runFolderWorkspaceTitleAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps,
stop: (reason: string, clearError?: boolean) => true,
retry: (reason: string) => false
): Promise<boolean> {
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
return stop('folder workspace is not pending title rename', true)
}
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
if (!folderPath) {
return stop('folder workspace path unavailable')
}
const settings = deps.getSettings()
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
if (!resolvedParams.ok) {
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
const target = await resolveGenerationTarget(
folderPath,
resolvedParams.params.agentId,
null,
deps
)
if (!target) {
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
return retry('could not prepare generation environment')
}
const generated = await generateBranchNameFromContext(
{ firstPrompt: prompt, assistantMessage },
resolvedParams.params,
target
)
if (!generated.success) {
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error)
}
return retry(`generation failed: ${generated.error}`)
}
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers())
if (!localEnv.ok) {
return null
}
return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) }
const newDisplayName = humanizeBranchSlug(generated.slug)
deps.setDisplayName(worktreeId, newDisplayName)
deps.setRenameError(worktreeId, null)
deps.onRenamed(worktreeId)
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
return true
}
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import {
renameWorktreeFolderOnFirstWork,
type FirstWorkFolderRenameDeps
} from './first-work-folder-rename'
const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo
const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings
const OLD_ID = 'repo1::/ws/cunner'
function makeDeps(overrides: Partial<FirstWorkFolderRenameDeps> = {}): FirstWorkFolderRenameDeps {
return {
getRepo: vi.fn(() => REPO),
getSettings: vi.fn(() => SETTINGS),
migrateWorktreeIdentity: vi.fn(),
notifyWorktreeRenamed: vi.fn(),
pathExists: vi.fn(async () => false),
moveWorktree: vi.fn(async () => {}),
...overrides
}
}
describe('renameWorktreeFolderOnFirstWork', () => {
const originalPlatform = process.platform
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
it('moves the folder and migrates identity on the happy path', async () => {
const deps = makeDeps()
const result = await renameWorktreeFolderOnFirstWork(OLD_ID, 'worktree-creation-spinner', deps)
expect(result).toBe(true)
expect(deps.moveWorktree).toHaveBeenCalledWith(
'/repos/orca',
'/ws/cunner',
'/ws/worktree-creation-spinner'
)
expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith(
OLD_ID,
'repo1::/ws/worktree-creation-spinner'
)
expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith(
'repo1',
OLD_ID,
'repo1::/ws/worktree-creation-spinner'
)
})
it('skips (no move) when the destination already exists', async () => {
const deps = makeDeps({ pathExists: vi.fn(async () => true) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
expect(deps.migrateWorktreeIdentity).not.toHaveBeenCalled()
})
it('skips remote worktrees without moving', async () => {
const deps = makeDeps({ getRepo: vi.fn(() => ({ ...REPO, connectionId: 'ssh1' })) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
it('skips runtime-owned worktrees without moving', async () => {
const deps = makeDeps({
getRepo: vi.fn(() => ({ ...REPO, executionHostId: 'runtime:gpu-vm' as const }))
})
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
it('returns false when the repo is unknown', async () => {
const deps = makeDeps({ getRepo: vi.fn(() => undefined) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,63 @@
// Why: after the first-work branch+display rename, the worktree's on-disk folder
// still carries its creature name (e.g. `cunner`), which is confusing once the
// branch reads `worktree-creation-spinner`. This module aligns the folder with
// the new branch leaf via `git worktree move`, then migrates Orca's path-derived
// worktree identity so meta, tabs, and the live PTY session carry over. It is
// best-effort and local-only — remote/Windows/locked/dest-taken all degrade to
// "folder kept" without disturbing the rename that already succeeded.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target'
export type FirstWorkFolderRenameDeps = {
getRepo: (repoId: string) => Repo | undefined
getSettings: () => GlobalSettings
/** Re-key all worktreeId-keyed state from the old (path-derived) id to the new. */
migrateWorktreeIdentity: (oldWorktreeId: string, newWorktreeId: string) => void
/** Invalidate caches + tell the renderer the worktree's id changed (old->new) so
* it re-keys its state instead of treating the rename as a deletion. */
notifyWorktreeRenamed: (repoId: string, oldWorktreeId: string, newWorktreeId: string) => void
/** True when the path already exists — git worktree move refuses a taken dest. */
pathExists: (path: string) => Promise<boolean>
moveWorktree: (repoPath: string, oldPath: string, newPath: string) => Promise<void>
}
/**
* Rename a worktree's folder to match its work-derived branch leaf. Returns true
* only when the folder was actually moved; false (folder kept) for every skip or
* graceful-degrade case. Throws only on an unexpected git failure mid-move — the
* caller swallows it so the branch/display rename is never undone.
*/
export async function renameWorktreeFolderOnFirstWork(
worktreeId: string,
newLeaf: string,
deps: FirstWorkFolderRenameDeps
): Promise<boolean> {
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
if (!repo || !parsed) {
return false
}
const plan = planWorktreeFolderRename({
repoId: repo.id,
repoPath: repo.path,
oldWorktreePath: parsed.worktreePath,
newLeaf,
settings: deps.getSettings(),
platform: process.platform,
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID
})
if (!plan) {
return false
}
if (await deps.pathExists(plan.newPath)) {
return false
}
await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath)
// Order: move first (point of no return), then re-key identity synchronously so
// nothing interleaves before the worktree's state is re-bound to the new id.
deps.migrateWorktreeIdentity(worktreeId, plan.newWorktreeId)
deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId)
return true
}
@@ -0,0 +1,30 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { CommitMessageGenerationTarget } from '../text-generation/commit-message-text-generation'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
} from '../text-generation/commit-message-agent-environment'
/** Resolve where the branch-name generation runs: a remote SSH provider when one
* is present, else the local agent env (null when that env can't be prepared). */
export async function resolveGenerationTarget(
worktreePath: string,
agentId: string,
provider: SshGitProvider | null,
deps: { getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined }
): Promise<CommitMessageGenerationTarget | null> {
if (provider) {
return {
kind: 'remote',
cwd: worktreePath,
execute: (plan, cwd, timeoutMs, operation) =>
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
}
}
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers())
if (!localEnv.ok) {
return null
}
return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) }
}
@@ -0,0 +1,137 @@
/**
* Benchmark-style regression test for the OpenCode MessagePart flood.
*
* Drives the REAL agent-hook HTTP pipeline (loopback socket, body read,
* JSON.parse, normalization, listener fanout) with two client behaviors:
*
* - "legacy plugin": one POST per streamed part update, each carrying the
* FULL accumulated reply text (how plugin builds before the throttle fix
* behaved) — O(n²) bytes per turn.
* - "throttled plugin": leading + trailing-edge coalesced posts at 250ms
* cadence with text capped at 4000 chars (current plugin behavior).
*
* The byte/post-count assertions are deterministic; wall-clock timings are
* logged as benchmark evidence (see notes/windows-perf-progress.md) but not
* asserted, to keep CI stable.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
import { AgentHookServer } from './server'
const PANE = makePaneKey('tab-bench', '99999999-9999-4999-8999-999999999999')
// A realistic long streaming reply: ~120 KB final text arriving in 400
// part updates (OpenCode re-publishes the whole part per append).
const FINAL_REPLY_CHARS = 120_000
const LEGACY_PART_UPDATES = 400
// Throttled plugin posts at most one MessagePart per 250ms. A ~30s turn
// yields ~120 posts; we use that worst-case count with the 4000-char cap.
const THROTTLED_POSTS = 120
const THROTTLED_TEXT_CAP = 4_000
describe('OpenCode MessagePart flood benchmark', () => {
let server: AgentHookServer
let tempDir: string
let listenerEvents: number
beforeEach(async () => {
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
tempDir = mkdtempSync(join(tmpdir(), 'orca-hook-bench-'))
server = new AgentHookServer()
listenerEvents = 0
server.setListener(() => {
listenerEvents++
})
await server.start({ env: 'production', userDataPath: tempDir })
})
afterEach(() => {
server.stop()
rmSync(tempDir, { recursive: true, force: true })
})
async function postMessagePart(env: Record<string, string>, text: string): Promise<void> {
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey: PANE,
tabId: 'tab-bench',
worktreeId: 'wt-bench',
env: 'production',
payload: {
hook_event_name: 'MessagePart',
role: 'assistant',
text,
messageID: 'msg-bench',
sessionID: 'session-bench'
}
})
})
expect(response.status).toBe(204)
}
it('throttled plugin behavior cuts per-turn hook-pipeline bytes by >40x', async () => {
const env = server.buildPtyEnv()
expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy()
// Legacy: full accumulated text per part update.
let legacyBytes = 0
const legacyStart = performance.now()
for (let i = 1; i <= LEGACY_PART_UPDATES; i++) {
const text = 'x'.repeat(Math.floor((FINAL_REPLY_CHARS * i) / LEGACY_PART_UPDATES))
legacyBytes += text.length
await postMessagePart(env, text)
}
const legacyMs = performance.now() - legacyStart
const legacyEvents = listenerEvents
listenerEvents = 0
// Throttled: bounded post count, bounded text.
let throttledBytes = 0
const throttledStart = performance.now()
for (let i = 1; i <= THROTTLED_POSTS; i++) {
const text = 'x'.repeat(THROTTLED_TEXT_CAP)
throttledBytes += text.length
await postMessagePart(env, text)
}
const throttledMs = performance.now() - throttledStart
const throttledEvents = listenerEvents
// eslint-disable-next-line no-console
console.log(
`[bench] legacy: ${LEGACY_PART_UPDATES} posts, ${(legacyBytes / 1024 / 1024).toFixed(1)} MB, ` +
`${legacyMs.toFixed(0)} ms, ${legacyEvents} listener fanouts | ` +
`throttled: ${THROTTLED_POSTS} posts, ${(throttledBytes / 1024).toFixed(0)} KB, ` +
`${throttledMs.toFixed(0)} ms, ${throttledEvents} listener fanouts`
)
// Deterministic: the turn's total text volume through the main process
// drops from O(n²) (~23 MB here) to O(posts × cap) (~470 KB here, >40x
// less). Real turns stream far more than 400 part updates, so the
// real-world ratio is larger still.
expect(throttledBytes).toBeLessThan(legacyBytes / 40)
expect(THROTTLED_POSTS).toBeLessThan(LEGACY_PART_UPDATES / 3 + 1)
}, 120_000)
})
+32
View File
@@ -3842,6 +3842,38 @@ describe('OpenCode hook normalization', () => {
expect(result?.payload.lastAssistantMessage).toBe('Hello! How can I help?')
})
it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => {
// Why: plugin builds installed before the throttle/cap fix re-post the
// full accumulated reply on every streamed part update. The listener must
// bound the text so each event's status compare, IPC fanout, and renderer
// store update stay O(cap) instead of O(reply length).
const assistant = _internals.normalizeHookPayload(
'opencode',
buildBody({
hook_event_name: 'MessagePart',
role: 'assistant',
text: 'a'.repeat(500_000)
}),
'production'
)
expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000)
// Why: prompt has always been single-line-capped at 200 by
// normalizeAgentStatusObject; this asserts the oversized input still
// flows through without blowing past that bound.
const user = _internals.normalizeHookPayload(
'opencode',
buildBody({
hook_event_name: 'MessagePart',
role: 'user',
text: 'u'.repeat(500_000),
messageID: 'msg-cap'
}),
'production'
)
expect(user?.payload.prompt?.length).toBe(200)
})
it('subsequent SessionIdle preserves cached prompt + assistant message', () => {
_internals.normalizeHookPayload(
'opencode',
@@ -0,0 +1,165 @@
import { basename, extname } from 'path'
import {
aiVaultAgentLabel,
buildAiVaultResumeCommand,
type AiVaultAgent,
type AiVaultSession,
type AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
extractPreviewContentText,
extractString,
normalizePreviewText,
timestampMs
} from './session-scanner-values'
const SESSION_PREVIEW_MESSAGE_LIMIT = 5
export function createAccumulator(args: {
agent: AiVaultAgent
file: FileWithMtime
sessionId: string
}): SessionAccumulator {
return {
agent: args.agent,
sessionId: args.sessionId,
title: null,
fallbackTitle: null,
cwd: null,
branch: null,
model: null,
filePath: args.file.path,
createdAt: null,
updatedAt: null,
modifiedAt: args.file.modifiedAt,
messageCount: 0,
totalTokens: 0,
previewMessages: [],
latestTimestampMs: 0
}
}
export function finalizeSession(
accumulator: SessionAccumulator,
platform: NodeJS.Platform,
options: { codexHome?: string | null } = {}
): AiVaultSession | null {
const sessionId = accumulator.sessionId.trim()
if (!sessionId) {
return null
}
const title =
accumulator.title ||
accumulator.fallbackTitle ||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
return {
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
agent: accumulator.agent,
sessionId,
title,
cwd: accumulator.cwd,
branch: accumulator.branch,
model: accumulator.model,
filePath: accumulator.filePath,
codexHome: accumulator.agent === 'codex' ? (options.codexHome ?? null) : null,
createdAt: accumulator.createdAt,
updatedAt: accumulator.updatedAt,
modifiedAt: accumulator.modifiedAt,
messageCount: accumulator.messageCount,
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
resumeCommand: buildAiVaultResumeCommand({
agent: accumulator.agent,
sessionId,
cwd: accumulator.cwd,
platform,
codexHome: options.codexHome
})
}
}
export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void {
const parsed = timestampMs(timestamp)
if (!Number.isFinite(parsed)) {
return
}
const iso = new Date(parsed).toISOString()
if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) {
accumulator.createdAt = iso
}
if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) {
accumulator.updatedAt = iso
accumulator.latestTimestampMs = parsed
}
}
export function addPreviewMessage(
accumulator: SessionAccumulator,
args: {
role: AiVaultSessionPreviewMessage['role']
text: string | null
timestamp?: unknown
}
): void {
const text = normalizePreviewText(args.text ?? '')
if (!text) {
return
}
accumulator.previewMessages.push({
role: args.role,
text,
timestamp: timestampIso(args.timestamp)
})
if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) {
accumulator.previewMessages.shift()
}
}
export function addPreviewContent(
accumulator: SessionAccumulator,
role: AiVaultSessionPreviewMessage['role'],
content: unknown,
timestamp?: unknown
): void {
addPreviewMessage(accumulator, {
role,
text: extractPreviewContentText(content),
timestamp
})
}
export function timestampIso(value: unknown): string | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
}
export function updateLatestLocation(
accumulator: SessionAccumulator,
record: Record<string, unknown>
): void {
const timestamp = extractString(record.timestamp)
const parsed = timestamp ? Date.parse(timestamp) : accumulator.latestTimestampMs
if (!Number.isFinite(parsed) || parsed < accumulator.latestTimestampMs) {
return
}
const cwd = extractString(record.cwd)
const branch = extractString(record.gitBranch)
if (cwd) {
accumulator.cwd = cwd
}
if (branch) {
accumulator.branch = branch
}
}
export function sessionSortTime(session: AiVaultSession): number {
return Date.parse(session.updatedAt ?? session.modifiedAt)
}
export function sessionIdFromFileName(filePath: string): string {
const fileName = basename(filePath, extname(filePath))
const match = fileName.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
return match?.[0] ?? fileName
}
@@ -0,0 +1,27 @@
import { dirname, resolve } from 'path'
export function codexHomeForSessionsDir(
sessionsDir: string,
defaultCodexHomeDir: string
): string | null {
const codexHome = dirname(sessionsDir)
return codexHome === defaultCodexHomeDir ? null : codexHome
}
export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] {
const seen = new Set<string>()
const unique: string[] = []
for (const path of paths) {
const trimmed = path.trim()
if (!trimmed) {
continue
}
const key = resolve(trimmed)
if (seen.has(key)) {
continue
}
seen.add(key)
unique.push(trimmed)
}
return unique
}
@@ -0,0 +1,95 @@
import { readdir, stat } from 'fs/promises'
import { basename, delimiter, extname, join } from 'path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
export async function discoverFiles(args: {
rootDir: string
limit: number
agent: AiVaultAgent
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
}): Promise<SessionFileDiscovery> {
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate
})
const files: FileWithMtime[] = []
for (const path of paths) {
try {
const fileStat = await stat(path)
files.push({
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
})
} catch (err) {
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
}
}
return {
agent: args.agent,
rootDir: args.rootDir,
files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit)
}
}
export async function discoverOpenClawFiles(args: {
rootDirs: string[]
limit: number
issues: AiVaultScanIssue[]
}): Promise<SessionFileDiscovery> {
const discoveries = await Promise.all(
args.rootDirs.map((rootDir) =>
discoverFiles({
rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'),
limit: args.limit,
agent: 'openclaw',
issues: args.issues,
extensions: ['.jsonl'],
filePredicate: (path) => path.split(/[\\/]/).includes('sessions')
})
)
)
const files = discoveries
.flatMap((discovery) => discovery.files)
.sort((left, right) => right.mtimeMs - left.mtimeMs)
.slice(0, args.limit)
return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files }
}
export async function walkSessionFiles(
dirPath: string,
agent: AiVaultAgent,
issues: AiVaultScanIssue[],
options: {
extensions: Set<string>
filePredicate?: (path: string) => boolean
}
): Promise<string[]> {
let entries
try {
entries = await readdir(dirPath, { withFileTypes: true })
} catch {
return []
}
const files: string[] = []
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
files.push(...(await walkSessionFiles(fullPath, agent, issues, options)))
continue
}
if (
entry.isFile() &&
options.extensions.has(extname(entry.name).toLowerCase()) &&
(options.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
}
}
return files
}
@@ -0,0 +1,267 @@
import { createReadStream } from 'fs'
import { readFile } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { createInterface } from 'readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
addPreviewMessage,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
arrayValue,
asRecord,
extractContentText,
extractMessageText,
extractPreviewContentText,
extractString,
firstString,
normalizeTitleText,
parseJsonObject,
readJsonObjectIfExists,
tokenTotal
} from './session-scanner-values'
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
if (!metadata) {
return null
}
const accumulator = createAccumulator({
agent: 'rovo',
file,
sessionId: basename(dirname(file.path))
})
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
accumulator.cwd = firstString(metadata, [
'workspace_path',
'workspacePath',
'workspace',
'cwd',
'working_directory',
'workingDirectory',
'project_path',
'projectPath'
])
updateTimeline(
accumulator,
extractString(metadata.created_at) ?? extractString(metadata.createdAt)
)
updateTimeline(
accumulator,
extractString(metadata.updated_at) ?? extractString(metadata.updatedAt)
)
const contextPath = join(dirname(file.path), 'session_context.json')
const context = await readJsonObjectIfExists(contextPath)
if (context) {
consumeRovoSessionContext(accumulator, context)
}
return finalizeSession(accumulator, platform)
}
export function consumeRovoSessionContext(
accumulator: SessionAccumulator,
context: Record<string, unknown>
): void {
for (const message of arrayValue(context.messages)) {
const record = asRecord(message)
const role = extractString(record?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
updateTimeline(accumulator, extractString(record?.timestamp))
if (role === 'user') {
accumulator.title ??= extractContentText(record?.content)
}
addPreviewContent(accumulator, role, record?.content, record?.timestamp)
}
}
for (const historyEntry of arrayValue(context.message_history)) {
consumeRovoHistoryEntry(accumulator, asRecord(historyEntry))
}
}
export function consumeRovoHistoryEntry(
accumulator: SessionAccumulator,
record: Record<string, unknown> | null
): void {
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
const role = extractString(record.role) ?? rovoRoleFromKind(record.kind)
if (role !== 'user' && role !== 'assistant') {
return
}
const text = rovoPartsText(arrayValue(record.parts), role)
if (!text) {
return
}
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= text
}
addPreviewMessage(accumulator, {
role,
text,
timestamp: record.timestamp
})
}
export function rovoRoleFromKind(value: unknown): 'user' | 'assistant' | null {
if (value === 'request') {
return 'user'
}
if (value === 'response') {
return 'assistant'
}
return null
}
export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): string | null {
const texts: string[] = []
for (const part of parts) {
const record = asRecord(part)
if (!record) {
continue
}
const kind = extractString(record.part_kind)
if (role === 'user' && kind !== 'user-prompt' && kind !== 'text') {
continue
}
if (role === 'assistant' && kind !== 'text') {
continue
}
const text = extractString(record.content) ?? extractString(record.text)
if (text) {
texts.push(text)
}
}
return normalizeTitleText(texts.join(' '))
}
export async function parseMessageGraphSessionFile(
agent: 'openclaw' | 'pi',
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent,
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'session') {
const sessionId = extractString(record.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'model_change') {
accumulator.model = extractString(record.modelId) ?? accumulator.model
continue
}
if (record.type !== 'message') {
continue
}
const message = asRecord(record.message)
const role = extractString(message?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= extractMessageText(message)
} else {
accumulator.model = extractString(message?.model) ?? accumulator.model
accumulator.totalTokens += tokenTotal(message?.usage)
}
addPreviewContent(accumulator, role, message?.content, record.timestamp)
}
}
return finalizeSession(accumulator, platform)
}
export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'droid',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, record.timestamp)
if (record.type === 'session_start') {
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'system') {
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
accumulator.model = extractString(record.model) ?? accumulator.model
}
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
if (streamSessionId) {
accumulator.sessionId = streamSessionId
}
if (record.type === 'message') {
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??=
normalizeTitleText(extractString(record.text) ?? '') ||
extractMessageText(asRecord(record.message))
}
addPreviewMessage(accumulator, {
role,
text:
extractString(record.text) ??
extractPreviewContentText(asRecord(record.message)?.content),
timestamp: record.timestamp
})
}
} else if (record.type === 'completion') {
accumulator.messageCount++
accumulator.totalTokens += tokenTotal(record.usage)
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(record.finalText),
timestamp: record.timestamp
})
}
}
return finalizeSession(accumulator, platform)
}
@@ -0,0 +1,110 @@
import { createReadStream } from 'fs'
import { readFile } from 'fs/promises'
import { dirname, join } from 'path'
import { createInterface } from 'readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
asRecord,
extractString,
normalizeTitleText,
numberValue,
parseJsonObject
} from './session-scanner-values'
export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
if (!record) {
return null
}
const info = asRecord(record.info)
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
const accumulator = createAccumulator({ agent: 'grok', file, sessionId })
accumulator.cwd = extractString(info?.cwd)
accumulator.title =
normalizeTitleText(extractString(record.generated_title) ?? '') ??
normalizeTitleText(extractString(record.session_summary) ?? '')
accumulator.model = extractString(record.current_model_id)
accumulator.branch = extractString(record.head_branch)
accumulator.messageCount =
numberValue(record.num_chat_messages) || numberValue(record.num_messages)
updateTimeline(accumulator, extractString(record.created_at))
updateTimeline(accumulator, extractString(record.updated_at))
updateTimeline(accumulator, extractString(record.last_active_at))
await consumeGrokChatHistory(accumulator, dirname(file.path))
return finalizeSession(accumulator, platform)
}
async function consumeGrokChatHistory(
accumulator: SessionAccumulator,
sessionDir: string
): Promise<void> {
try {
const lines = createInterface({
input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const role = extractString(record.type)
if (role !== 'user' && role !== 'assistant') {
continue
}
const text = extractGrokContentText(record.content)
if (role === 'user') {
accumulator.title ??= normalizeTitleText(text ?? '')
}
addPreviewMessage(accumulator, {
role,
text,
timestamp: extractString(record.timestamp)
})
}
} catch {
// Summary-only sessions still provide enough metadata for the Vault list.
}
}
function extractGrokContentText(value: unknown): string | null {
const text = extractGrokRawContentText(value)
if (!text) {
return null
}
return text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim() || text
}
function extractGrokRawContentText(value: unknown): string | null {
if (typeof value === 'string') {
return extractString(value)
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
}
}
return extractString(parts.join(' '))
}
@@ -0,0 +1,300 @@
import { createReadStream } from 'fs'
import { readFile } from 'fs/promises'
import { createInterface } from 'readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateLatestLocation,
updateTimeline
} from './session-scanner-accumulator'
import {
arrayValue,
asRecord,
claudeUsageTotal,
extractContentText,
extractGitBranch,
extractMessageText,
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage,
tokenTotal
} from './session-scanner-values'
export async function parseClaudeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'claude',
file,
sessionId: sessionIdFromFileName(file.path)
})
let metaTitle: string | null = null
let generatedTitle: string | null = null
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
if (typeof record.sessionId === 'string' && record.sessionId.trim()) {
accumulator.sessionId = record.sessionId.trim()
}
updateTimeline(accumulator, extractString(record.timestamp))
updateLatestLocation(accumulator, record)
if (record.type === 'custom-title') {
accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '')
continue
}
if (record.type === 'ai-title') {
generatedTitle ??= normalizeTitleText(extractString(record.aiTitle) ?? '')
continue
}
if (record.type === 'agent-name' && !generatedTitle) {
metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '')
continue
}
if (record.type === 'user') {
accumulator.messageCount++
const title = extractMessageText(record.message)
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp)
if (title && record.isMeta !== true && !accumulator.title) {
accumulator.title = title
} else if (title && !metaTitle) {
metaTitle = title
}
continue
}
if (record.type === 'assistant') {
accumulator.messageCount++
const message = asRecord(record.message)
addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp)
const model = extractString(message?.model)
if (model) {
accumulator.model = model
}
accumulator.totalTokens += claudeUsageTotal(message?.usage)
}
}
accumulator.fallbackTitle = generatedTitle ?? metaTitle
return finalizeSession(accumulator, platform)
}
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
})
let previousTotals: CodexUsageSnapshot | null = null
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
const sessionId = extractString(payload.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
continue
}
if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
continue
}
if (!payload) {
continue
}
if (record.type === 'response_item' && payload.type === 'message') {
accumulator.messageCount++
if (payload.role === 'user' && !accumulator.title) {
accumulator.title = extractContentText(payload.content)
}
addPreviewContent(
accumulator,
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
payload.content,
record.timestamp
)
continue
}
if (record.type !== 'event_msg') {
continue
}
if (payload.type === 'user_message') {
accumulator.messageCount++
if (!accumulator.title) {
accumulator.title = extractContentText(payload.message)
}
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
continue
}
if (payload.type === 'agent_message') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
continue
}
if (payload.type !== 'token_count') {
continue
}
const info = asRecord(payload.info)
if (!info) {
continue
}
const totalUsage = normalizeCodexUsage(info.total_token_usage)
const lastUsage = normalizeCodexUsage(info.last_token_usage)
const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage
if (totalUsage) {
previousTotals = totalUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
}
return finalizeSession(accumulator, platform, { codexHome })
}
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
}
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
if (!record) {
return null
}
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
return finalizeSession(accumulator, platform)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const setRecord = asRecord(record.$set)
if (setRecord) {
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
continue
}
const sessionId = extractString(record.sessionId)
if (sessionId) {
accumulator.sessionId = sessionId
}
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
consumeGeminiMessage(accumulator, record)
}
return finalizeSession(accumulator, platform)
}
export function consumeGeminiMessage(
accumulator: SessionAccumulator,
record: Record<string, unknown> | null
): void {
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'user') {
accumulator.messageCount++
accumulator.title ??= extractContentText(record.content)
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
return
}
if (record.type === 'gemini') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
const model = extractString(record.model)
if (model) {
accumulator.model = model
}
accumulator.totalTokens += tokenTotal(record.tokens)
}
}
@@ -0,0 +1,238 @@
import { createReadStream } from 'fs'
import { readFile, readdir } from 'fs/promises'
import { join } from 'path'
import { createInterface } from 'readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
addPreviewMessage,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
arrayValue,
asRecord,
copilotModelMetricsTotal,
extractContentText,
extractMessageText,
extractPreviewContentText,
extractString,
extractTrustedFolder,
findOpenCodeStorageRoot,
normalizeTitleText,
numberValue,
parseJsonObject,
timeObjectValue,
tokenTotal
} from './session-scanner-values'
export async function parseCopilotSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'copilot',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
const data = asRecord(record.data)
if (record.type === 'session.start' && data) {
const sessionId = extractString(data.sessionId)
if (sessionId) {
accumulator.sessionId = sessionId
}
updateTimeline(accumulator, extractString(data.startTime))
continue
}
if (record.type === 'session.model_change' && data) {
accumulator.model = extractString(data.newModel) ?? accumulator.model
continue
}
if (record.type === 'session.info' && data) {
accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd
continue
}
if (record.type === 'user.message' && data) {
accumulator.messageCount++
accumulator.title ??= normalizeTitleText(
extractString(data.transformedContent) ?? extractString(data.content) ?? ''
)
addPreviewMessage(accumulator, {
role: 'user',
text: extractString(data.transformedContent) ?? extractString(data.content),
timestamp: record.timestamp
})
continue
}
if (record.type === 'assistant.message' && data) {
accumulator.messageCount++
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(data.content),
timestamp: record.timestamp
})
continue
}
if (record.type === 'session.shutdown' && data) {
accumulator.model = extractString(data.currentModel) ?? accumulator.model
accumulator.totalTokens += numberValue(data.currentTokens)
accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics)
}
}
return finalizeSession(accumulator, platform)
}
export async function parseCursorSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'cursor',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, extractString(record.timestamp))
const role = extractString(record.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??=
extractMessageText(record.message) ?? extractContentText(record.content)
}
addPreviewContent(
accumulator,
role,
asRecord(record.message)?.content ?? record.content,
record.timestamp
)
}
}
return finalizeSession(accumulator, platform)
}
export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
if (!record) {
return null
}
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId })
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.directory)
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
updateTimeline(accumulator, timeObjectValue(record.time, 'updated'))
await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId)
return finalizeSession(accumulator, platform)
}
export async function consumeOpenCodeMessages(
accumulator: SessionAccumulator,
storageRoot: string | null,
sessionId: string
): Promise<void> {
if (!storageRoot) {
return
}
const messageDir = join(storageRoot, 'message', sessionId)
let entries
try {
entries = await readdir(messageDir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue
}
const message = asRecord(
JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown
)
if (!message) {
continue
}
const role = extractString(message.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
updateTimeline(accumulator, timeObjectValue(message.time, 'created'))
if (role === 'user') {
accumulator.title ??= extractString(asRecord(message.summary)?.title)
accumulator.title ??= extractString(asRecord(message.summary)?.body)
}
addPreviewMessage(accumulator, {
role,
text:
extractPreviewContentText(message.content) ??
extractString(asRecord(message.summary)?.body) ??
extractString(asRecord(message.summary)?.title),
timestamp: timeObjectValue(message.time, 'created')
})
accumulator.model =
extractString(asRecord(message.model)?.modelID) ||
extractString(message.modelID) ||
accumulator.model
accumulator.totalTokens += tokenTotal(message.tokens)
}
}
}
export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
if (!record) {
return null
}
const accumulator = createAccumulator({
agent: 'hermes',
file,
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path)
})
accumulator.model = extractString(record.model)
accumulator.cwd = extractString(record.cwd)
updateTimeline(accumulator, extractString(record.session_start))
updateTimeline(accumulator, extractString(record.last_updated))
for (const message of arrayValue(record.messages)) {
const messageRecord = asRecord(message)
const role = extractString(messageRecord?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??= extractContentText(messageRecord?.content)
}
addPreviewContent(accumulator, role, messageRecord?.content)
}
}
if (accumulator.messageCount === 0) {
accumulator.messageCount = numberValue(record.message_count)
}
return finalizeSession(accumulator, platform)
}
@@ -0,0 +1,106 @@
import type { CodexUsageSnapshot } from './session-scanner-types'
import { asRecord } from './session-scanner-values'
export function tokenTotal(value: unknown): number {
const usage = asRecord(value)
if (!usage) {
return 0
}
const explicitTotal =
numberValue(usage.total) || numberValue(usage.totalTokens) || numberValue(usage.total_tokens)
if (explicitTotal > 0) {
return explicitTotal
}
const fields: unknown[] = [
usage.input,
usage.inputTokens,
usage.input_tokens,
usage.output,
usage.outputTokens,
usage.output_tokens,
usage.cacheRead,
usage.cacheReadTokens,
usage.cache_read_input_tokens,
usage.cacheWrite,
usage.cacheWriteTokens,
usage.cache_creation_input_tokens,
usage.cached,
usage.cachedInputTokens,
usage.cached_input_tokens,
usage.reasoning,
usage.reasoningOutputTokens,
usage.reasoning_output_tokens
]
return fields.reduce<number>((total, current) => total + numberValue(current), 0)
}
export function copilotModelMetricsTotal(value: unknown): number {
const metrics = asRecord(value)
if (!metrics) {
return 0
}
let total = 0
for (const metric of Object.values(metrics)) {
const record = asRecord(metric)
const usage = asRecord(record?.usage)
if (!usage) {
continue
}
total += tokenTotal(usage)
}
return total
}
export function claudeUsageTotal(value: unknown): number {
const usage = asRecord(value)
if (!usage) {
return 0
}
return (
numberValue(usage.input_tokens) +
numberValue(usage.output_tokens) +
numberValue(usage.cache_read_input_tokens) +
numberValue(usage.cache_creation_input_tokens)
)
}
export function normalizeCodexUsage(value: unknown): CodexUsageSnapshot | null {
const usage = asRecord(value)
if (!usage) {
return null
}
const inputTokens = numberValue(usage.input_tokens)
const cachedInputTokens = numberValue(usage.cached_input_tokens ?? usage.cache_read_input_tokens)
const outputTokens = numberValue(usage.output_tokens)
const reasoningOutputTokens = numberValue(usage.reasoning_output_tokens)
const totalTokens = numberValue(usage.total_tokens)
return {
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens
}
}
export function subtractCodexUsage(
current: CodexUsageSnapshot,
previous: CodexUsageSnapshot | null
): CodexUsageSnapshot {
return {
inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0),
cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0),
outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0),
reasoningOutputTokens: Math.max(
current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0),
0
),
totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0)
}
}
export function numberValue(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
@@ -0,0 +1,76 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type {
AiVaultScanIssue,
AiVaultSession,
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
codexSessionsDir?: string
additionalCodexSessionsDirs?: readonly string[]
geminiSessionsDir?: string
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
grokSessionsDir?: string
hermesSessionsDir?: string
rovoSessionsDir?: string
openclawStateDir?: string
openclawLegacyStateDir?: string
piSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
limit?: number
limitPerAgent?: number
platform?: NodeJS.Platform
}
export type FileWithMtime = {
path: string
mtimeMs: number
modifiedAt: string
}
export type SessionFileCandidate = {
agent: AiVaultAgent
file: FileWithMtime
codexHome: string | null
}
export type SessionFileDiscovery = {
agent: AiVaultAgent
rootDir: string
files: FileWithMtime[]
}
export type SessionParseResult = {
session: AiVaultSession | null
issue: AiVaultScanIssue | null
}
export type SessionAccumulator = {
agent: AiVaultAgent
sessionId: string
title: string | null
fallbackTitle: string | null
cwd: string | null
branch: string | null
model: string | null
filePath: string
createdAt: string | null
updatedAt: string | null
modifiedAt: string
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
latestTimestampMs: number
}
export type CodexUsageSnapshot = {
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
+246
View File
@@ -0,0 +1,246 @@
import { homedir } from 'os'
import { basename, dirname, join } from 'path'
import { readFile } from 'fs/promises'
const SESSION_PREVIEW_TEXT_LIMIT = 220
const HIDDEN_USER_CONTEXT_BLOCK_PATTERN =
/<(?:codex_internal_context\b[^>]*|goal_context)>[\s\S]*?<\/(?:codex_internal_context|goal_context)>/gi
export function timestampMs(value: unknown): number {
if (typeof value === 'string') {
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : Number.NaN
}
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return Number.NaN
}
return value > 1_000_000_000_000 ? value : value * 1000
}
export function parseJsonObject(line: string): Record<string, unknown> | null {
if (!line.trim()) {
return null
}
try {
const parsed = JSON.parse(line) as unknown
return asRecord(parsed)
} catch {
return null
}
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function extractString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
export function extractModel(value: unknown): string | null {
const record = asRecord(value)
if (!record) {
return null
}
return (
extractString(record.model) ||
extractString(record.model_name) ||
extractString(asRecord(record.metadata)?.model) ||
extractString(asRecord(record.info)?.model) ||
null
)
}
export function extractGitBranch(value: unknown): string | null {
const git = asRecord(value)
if (!git) {
return null
}
return extractString(git.branch) || extractString(git.current_branch)
}
export function extractMessageText(value: unknown): string | null {
const message = asRecord(value)
if (!message) {
return null
}
return extractContentText(message.content)
}
export function extractContentText(value: unknown): string | null {
if (typeof value === 'string') {
return normalizeTitleText(value)
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
}
}
return normalizeTitleText(parts.join(' '))
}
export function normalizeTitleText(value: string): string | null {
const withoutReminders = value
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!withoutReminders) {
return null
}
if (/^# AGENTS\.md instructions for\b/i.test(withoutReminders)) {
return null
}
if (/^<INSTRUCTIONS>/i.test(withoutReminders)) {
return null
}
return withoutReminders.length > 96 ? `${withoutReminders.slice(0, 93)}...` : withoutReminders
}
export function extractPreviewContentText(value: unknown): string | null {
if (typeof value === 'string') {
return normalizePreviewText(value)
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) || extractString(record?.content)
if (text) {
parts.push(text)
}
}
return normalizePreviewText(parts.join(' '))
}
export function normalizePreviewText(value: string): string | null {
const normalized = value
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return null
}
if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^<INSTRUCTIONS>/i.test(normalized)) {
return null
}
return normalized.length > SESSION_PREVIEW_TEXT_LIMIT
? `${normalized.slice(0, SESSION_PREVIEW_TEXT_LIMIT - 3)}...`
: normalized
}
export async function readJsonObjectIfExists(
filePath: string
): Promise<Record<string, unknown> | null> {
try {
return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown)
} catch {
return null
}
}
export function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
export function firstString(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = extractString(record[key])
if (value) {
return value
}
}
return null
}
export function extractTrustedFolder(value: unknown): string | null {
const message = extractString(value)
if (!message) {
return null
}
return message.match(/^Folder (.+) has been added to trusted folders\.$/)?.[1] ?? null
}
export function timeObjectValue(value: unknown, key: string): string | null {
const record = asRecord(value)
if (!record) {
return null
}
const rawValue = record[key]
if (typeof rawValue === 'string') {
return rawValue
}
const parsed = timestampMs(rawValue)
if (!Number.isFinite(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
export function findOpenCodeStorageRoot(filePath: string): string | null {
const sessionDir = dirname(filePath)
const sessionRoot = dirname(sessionDir)
if (basename(sessionRoot) !== 'session') {
return null
}
return dirname(sessionRoot)
}
export function normalizePiSessionsDir(rawValue: string): string {
const trimmed = rawValue.trim()
if (!trimmed) {
return join(homedir(), '.pi', 'agent', 'sessions')
}
const normalized = trimmed.replace(/[\\/]+$/, '')
const leaf = basename(normalized)
if (leaf === 'sessions') {
return normalized
}
if (leaf === 'agent') {
return join(normalized, 'sessions')
}
if (leaf === '.pi') {
return join(normalized, 'agent', 'sessions')
}
return normalized
}
export function clampPositiveInteger(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback
}
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export {
claudeUsageTotal,
copilotModelMetricsTotal,
normalizeCodexUsage,
numberValue,
subtractCodexUsage,
tokenTotal
} from './session-scanner-token-values'
+636
View File
@@ -0,0 +1,636 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
function isolatedScanRoots(root: string) {
return {
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir: join(root, 'codex-sessions'),
geminiSessionsDir: join(root, 'gemini-sessions'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
grokSessionsDir: join(root, 'grok-sessions'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects')
}
}
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
describe('scanAiVaultSessions', () => {
it('indexes Claude and Codex transcripts with resume commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const claudeRoot = roots.claudeProjectsDir
const codexRoot = roots.codexSessionsDir
await mkdir(join(claudeRoot, 'project'), { recursive: true })
await mkdir(join(codexRoot, '2026', '05', '01'), { recursive: true })
await writeFile(
join(claudeRoot, 'project', 'claude-session.jsonl'),
[
JSON.stringify({
type: 'user',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
gitBranch: 'feature/vault',
isMeta: false,
message: { role: 'user', content: 'Implement the vault panel' }
}),
JSON.stringify({
type: 'assistant',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:02:00.000Z',
cwd: '/repo/app',
gitBranch: 'feature/vault',
message: {
model: 'claude-sonnet-4-5',
usage: {
input_tokens: 100,
output_tokens: 40,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 5
}
}
}),
JSON.stringify({
type: 'custom-title',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:03:00.000Z',
customTitle: 'Vault polish pass'
})
].join('\n')
)
await writeFile(
join(
codexRoot,
'2026',
'05',
'01',
'rollout-2026-05-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
),
[
JSON.stringify({
timestamp: '2026-05-01T11:00:00.000Z',
type: 'session_meta',
payload: {
id: '019f0000-1111-7222-8333-444444444444',
cwd: '/repo/app/packages/web',
git: { branch: 'feature/codex-vault' }
}
}),
JSON.stringify({
timestamp: '2026-05-01T11:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [
{ type: 'text', text: '# AGENTS.md instructions for /repo/app <INSTRUCTIONS>' }
]
}
}),
JSON.stringify({
timestamp: '2026-05-01T11:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Fix the resume picker filters' }]
}
}),
JSON.stringify({
timestamp: '2026-05-01T11:00:03.000Z',
type: 'turn_context',
payload: { cwd: '/repo/app/packages/web', model: 'gpt-5.3-codex' }
}),
JSON.stringify({
timestamp: '2026-05-01T11:00:04.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 500,
cached_input_tokens: 100,
output_tokens: 125,
reasoning_output_tokens: 25,
total_tokens: 625
}
}
}
}),
JSON.stringify({
timestamp: '2026-05-01T11:00:05.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 500,
cached_input_tokens: 100,
output_tokens: 125,
reasoning_output_tokens: 25,
total_tokens: 625
}
}
}
})
].join('\n')
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin'
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(2)
expect(result.sessions.map((session) => session.title).sort()).toEqual([
'Fix the resume picker filters',
'Vault polish pass'
])
const claude = result.sessions.find((session) => session.agent === 'claude')
expect(claude).toMatchObject({
sessionId: 'claude-session',
cwd: '/repo/app',
branch: 'feature/vault',
model: 'claude-sonnet-4-5',
messageCount: 2,
totalTokens: 155,
resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'"
})
const codex = result.sessions.find((session) => session.agent === 'codex')
expect(codex).toMatchObject({
sessionId: '019f0000-1111-7222-8333-444444444444',
cwd: '/repo/app/packages/web',
branch: 'feature/codex-vault',
model: 'gpt-5.3-codex',
messageCount: 2,
totalTokens: 625,
resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'`
})
})
it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-runtime-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const runtimeHome = join(root, 'codex-runtime-home', 'home')
const runtimeSessionsDir = join(runtimeHome, 'sessions')
await mkdir(join(runtimeSessionsDir, '2026', '06', '04'), { recursive: true })
await writeFile(
join(
runtimeSessionsDir,
'2026',
'06',
'04',
'rollout-2026-06-04T23-58-22-019e9693-64fc-7370-9c18-7e625c595d0f.jsonl'
),
jsonLines([
{
timestamp: '2026-06-04T23:58:22.000Z',
type: 'session_meta',
payload: {
id: '019e9693-64fc-7370-9c18-7e625c595d0f',
cwd: '/Users/nwparker/orca/workspaces/orca/mem4'
}
},
{
timestamp: '2026-06-04T23:58:23.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Resume this managed Codex session' }]
}
}
])
)
const result = await scanAiVaultSessions({
...roots,
additionalCodexSessionsDirs: [runtimeSessionsDir],
platform: 'darwin'
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'codex',
sessionId: '019e9693-64fc-7370-9c18-7e625c595d0f',
cwd: '/Users/nwparker/orca/workspaces/orca/mem4',
codexHome: runtimeHome,
resumeCommand: `cd '/Users/nwparker/orca/workspaces/orca/mem4' && CODEX_HOME='${runtimeHome}' codex resume '019e9693-64fc-7370-9c18-7e625c595d0f'`
})
})
it('skips hidden Codex context blocks when choosing session titles', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-hidden-context-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(join(roots.codexSessionsDir, '2026', '06', '11'), { recursive: true })
await writeFile(
join(roots.codexSessionsDir, '2026', '06', '11', 'rollout-hidden-context.jsonl'),
jsonLines([
{
timestamp: '2026-06-11T10:00:00.000Z',
type: 'session_meta',
payload: { id: 'hidden-context-session', cwd: '/repo/app' }
},
{
timestamp: '2026-06-11T10:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [
{
type: 'text',
text: '<codex_internal_context source="goal">\\nKeep going\\n</codex_internal_context>'
}
]
}
},
{
timestamp: '2026-06-11T10:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Fix the title shown in the session list' }]
}
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin'
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]?.title).toBe('Fix the title shown in the session list')
expect(result.sessions[0]?.previewMessages.map((message) => message.text)).toEqual([
'Fix the title shown in the session list'
])
})
it('indexes every supported agent transcript format with native resume commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-all-agents-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Claude title' }
}
])
)
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
await writeFile(
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
jsonLines([
{
timestamp: '2026-05-01T10:01:00.000Z',
type: 'session_meta',
payload: { id: 'codex-session', cwd: '/tmp/codex' }
},
{
timestamp: '2026-05-01T10:01:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Codex title' }]
}
}
])
)
await mkdir(roots.geminiSessionsDir, { recursive: true })
await writeFile(
join(roots.geminiSessionsDir, 'gemini-session.json'),
JSON.stringify({
sessionId: 'gemini-session',
startTime: '2026-05-01T10:02:00.000Z',
lastUpdated: '2026-05-01T10:02:01.000Z',
messages: [
{
type: 'user',
timestamp: '2026-05-01T10:02:00.000Z',
content: [{ text: 'Gemini title' }]
},
{
type: 'gemini',
timestamp: '2026-05-01T10:02:01.000Z',
model: 'gemini-2.5-pro',
tokens: { input: 10, output: 5 }
}
]
})
)
await mkdir(roots.copilotSessionsDir, { recursive: true })
await writeFile(
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
jsonLines([
{
type: 'session.start',
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
timestamp: '2026-05-01T10:03:00.000Z'
},
{
type: 'session.info',
data: {
infoType: 'folder_trust',
message: 'Folder /tmp/copilot has been added to trusted folders.'
},
timestamp: '2026-05-01T10:03:01.000Z'
},
{
type: 'user.message',
data: { transformedContent: 'Copilot title' },
timestamp: '2026-05-01T10:03:02.000Z'
}
])
)
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
await writeFile(
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
jsonLines([
{
role: 'user',
message: { content: [{ type: 'text', text: 'Cursor title' }] }
},
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
])
)
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
await writeFile(
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
JSON.stringify({
id: 'opencode-session',
directory: '/tmp/opencode',
title: 'OpenCode title',
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
})
)
await writeFile(
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
JSON.stringify({
role: 'user',
summary: { title: 'OpenCode title' },
time: { created: 1_777_634_000_000 },
tokens: { input: 7, output: 3 }
})
)
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
recursive: true
})
await writeFile(
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
JSON.stringify({
info: { id: 'grok-session', cwd: '/tmp/grok' },
session_summary: '',
created_at: '2026-05-01T10:04:00.000Z',
updated_at: '2026-05-01T10:04:01.000Z',
num_chat_messages: 2,
current_model_id: 'grok-build',
head_branch: 'feature/grok-vault'
})
)
await writeFile(
join(
roots.grokSessionsDir,
encodeURIComponent('/tmp/grok'),
'grok-session',
'chat_history.jsonl'
),
jsonLines([
{
type: 'user',
content: [
{
type: 'text',
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
}
]
},
{ type: 'assistant', content: 'Done' }
])
)
await mkdir(roots.hermesSessionsDir, { recursive: true })
await writeFile(
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
JSON.stringify({
session_id: 'hermes-session',
model: 'hermes-1',
cwd: '/tmp/hermes',
session_start: '2026-05-01T10:05:00.000Z',
last_updated: '2026-05-01T10:05:01.000Z',
messages: [{ role: 'user', content: 'Hermes title' }]
})
)
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
)
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
JSON.stringify({
message_history: [
{
kind: 'request',
timestamp: '2026-05-01T10:06:00.000Z',
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
}
]
})
)
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
await writeFile(
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
jsonLines([
{
type: 'session',
id: 'openclaw-session',
timestamp: '2026-05-01T10:07:00.000Z',
cwd: '/tmp/openclaw'
},
{
type: 'message',
timestamp: '2026-05-01T10:07:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
}
])
)
await mkdir(roots.piSessionsDir, { recursive: true })
await writeFile(
join(roots.piSessionsDir, 'pi-session.jsonl'),
jsonLines([
{
type: 'session',
id: 'pi-session',
timestamp: '2026-05-01T10:08:00.000Z',
cwd: '/tmp/pi'
},
{
type: 'message',
timestamp: '2026-05-01T10:08:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
}
])
)
await mkdir(roots.droidSessionsDir, { recursive: true })
await writeFile(
join(roots.droidSessionsDir, 'droid-session.jsonl'),
jsonLines([
{
type: 'system',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:00.000Z',
model: 'droid-model',
cwd: '/tmp/droid'
},
{
type: 'message',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:01.000Z',
role: 'user',
text: 'Droid title'
},
{
type: 'completion',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:02.000Z',
usage: { input_tokens: 2, output_tokens: 3 }
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin',
limit: 20
})
expect(result.issues).toEqual([])
expect(new Set(result.sessions.map((session) => session.agent))).toEqual(
new Set(AI_VAULT_AGENTS)
)
const commandByAgent = new Map(
result.sessions.map((session) => [session.agent, session.resumeCommand])
)
expect(commandByAgent.get('claude')).toBe(
"cd '/tmp/claude' && claude --resume 'claude-session'"
)
expect(commandByAgent.get('codex')).toBe(
`cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'`
)
expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'")
expect(commandByAgent.get('copilot')).toBe(
"cd '/tmp/copilot' && copilot --resume='copilot-session'"
)
expect(commandByAgent.get('cursor')).toBe("cursor-agent --resume 'cursor-session'")
expect(commandByAgent.get('opencode')).toBe(
"cd '/tmp/opencode' && opencode --session 'opencode-session'"
)
expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'")
expect(commandByAgent.get('hermes')).toBe(
"cd '/tmp/hermes' && hermes --resume 'hermes-session'"
)
expect(commandByAgent.get('rovo')).toBe(
"cd '/tmp/rovo' && acli rovodev run --restore 'rovo-session'"
)
expect(commandByAgent.get('openclaw')).toBe(
"cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'"
)
expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'")
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
})
})
describe('buildAiVaultResumeCommand', () => {
it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => {
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: 'C:\\Users\\Ada Lovelace\\repo',
platform: 'win32'
})
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
})
it('carries non-default Codex homes in copied resume commands', () => {
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: '/repo/app',
platform: 'darwin',
codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
})
).toBe(
"cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
)
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: 'C:\\Users\\Ada Lovelace\\repo',
platform: 'win32',
codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
})
).toBe(
'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
)
})
})
+319
View File
@@ -0,0 +1,319 @@
import { homedir } from 'os'
import { basename, join } from 'path'
import type {
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { sessionSortTime } from './session-scanner-accumulator'
import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import {
parseDroidSessionFile,
parseMessageGraphSessionFile,
parseRovoSessionFile
} from './session-scanner-graph-parsers'
import {
parseClaudeSessionFile,
parseCodexSessionFile,
parseGeminiSessionFile
} from './session-scanner-primary-parsers'
import {
parseCopilotSessionFile,
parseCursorSessionFile,
parseHermesSessionFile,
parseOpenCodeSessionFile
} from './session-scanner-secondary-parsers'
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery,
SessionParseResult
} from './session-scanner-types'
import {
clampPositiveInteger,
errorMessage,
normalizePiSessionsDir
} from './session-scanner-values'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
const CODEX_HOME_DIR = process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR
const CODEX_SESSIONS_DIR = join(CODEX_HOME_DIR, 'sessions')
const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp')
const COPILOT_SESSIONS_DIR = join(
process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'),
'session-state'
)
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
const GROK_SESSIONS_DIR = join(
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
'sessions'
)
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
const PI_SESSIONS_DIR = normalizePiSessionsDir(
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
)
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
const platform = options.platform ?? process.platform
const issues: AiVaultScanIssue[] = []
const codexSessionsDirs = uniqueCodexSessionsDirs([
options.codexSessionsDir ?? CODEX_SESSIONS_DIR,
...(options.additionalCodexSessionsDirs ?? [])
])
const discoveries = await Promise.all<SessionFileDiscovery>([
discoverFiles({
rootDir: options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
limit: limitPerAgent,
agent: 'claude',
issues,
extensions: ['.jsonl']
}),
...codexSessionsDirs.map((rootDir) =>
discoverFiles({
rootDir,
limit: limitPerAgent,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
),
discoverFiles({
rootDir: options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'gemini',
issues,
extensions: ['.json', '.jsonl']
}),
discoverFiles({
rootDir: options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'copilot',
issues,
extensions: ['.jsonl']
}),
discoverFiles({
rootDir: options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR,
limit: limitPerAgent,
agent: 'cursor',
issues,
extensions: ['.jsonl'],
filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts')
}),
discoverFiles({
rootDir: join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
limit: limitPerAgent,
agent: 'opencode',
issues,
extensions: ['.json']
}),
discoverFiles({
rootDir: options.grokSessionsDir ?? GROK_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'grok',
issues,
extensions: ['.json'],
filePredicate: (path) => basename(path) === 'summary.json'
}),
discoverFiles({
rootDir: options.hermesSessionsDir ?? HERMES_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'hermes',
issues,
extensions: ['.json'],
filePredicate: (path) => basename(path).startsWith('session_')
}),
discoverFiles({
rootDir: options.rovoSessionsDir ?? ROVO_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'rovo',
issues,
extensions: ['.json'],
filePredicate: (path) => basename(path) === 'metadata.json'
}),
discoverOpenClawFiles({
rootDirs: [
options.openclawStateDir ?? OPENCLAW_STATE_DIR,
options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot')
],
limit: limitPerAgent,
issues
}),
discoverFiles({
rootDir: options.piSessionsDir ?? PI_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'pi',
issues,
extensions: ['.jsonl']
}),
discoverFiles({
rootDir: options.droidSessionsDir ?? DROID_SESSIONS_DIR,
limit: limitPerAgent,
agent: 'droid',
issues,
extensions: ['.jsonl']
}),
discoverFiles({
rootDir: options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'),
limit: limitPerAgent,
agent: 'droid',
issues,
extensions: ['.jsonl']
})
])
const candidates = discoveries
.flatMap((discovery) =>
discovery.files.map(
(file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR)
: null
})
)
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
const parsedSessions = await parseSessionCandidates({
candidates,
limit,
platform,
issues
})
const sessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
return {
sessions,
issues,
scannedAt: new Date().toISOString()
}
}
async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
const sessions: AiVaultSession[] = []
let index = 0
while (index < args.candidates.length) {
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
break
}
const remaining = args.candidates.length - index
const needed = Math.max(args.limit - sessions.length, 1)
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
const results = await Promise.all(
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
)
for (const result of results) {
if (result.issue) {
args.issues.push(result.issue)
}
if (result.session) {
sessions.push(result.session)
}
}
index += batchSize
}
return sessions
}
async function parseSessionCandidate(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
): Promise<SessionParseResult> {
try {
const session = await parseAgentSessionFile(candidate, platform)
return { session, issue: null }
} catch (err) {
return {
session: null,
issue: {
agent: candidate.agent,
path: candidate.file.path,
message: errorMessage(err)
}
}
}
}
async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
switch (candidate.agent) {
case 'claude':
return parseClaudeSessionFile(candidate.file, platform)
case 'codex':
return parseCodexSessionFile(candidate.file, platform, candidate.codexHome)
case 'gemini':
return parseGeminiSessionFile(candidate.file, platform)
case 'copilot':
return parseCopilotSessionFile(candidate.file, platform)
case 'cursor':
return parseCursorSessionFile(candidate.file, platform)
case 'opencode':
return parseOpenCodeSessionFile(candidate.file, platform)
case 'grok':
return parseGrokSessionFile(candidate.file, platform)
case 'hermes':
return parseHermesSessionFile(candidate.file, platform)
case 'rovo':
return parseRovoSessionFile(candidate.file, platform)
case 'openclaw':
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
}
}
function canStopParsingSessions(
sessions: AiVaultSession[],
limit: number,
nextCandidateMtimeMs: number | undefined
): boolean {
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
return false
}
const visibleCutoff = sessions
.map(sessionSortTime)
.sort((left, right) => right - left)
.at(limit - 1)
// Transcript mtime is already our discovery bound and fallback sort key; older
// files cannot displace the current visible set once the cutoff is newer.
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
}
+71
View File
@@ -0,0 +1,71 @@
import type {
Automation,
AutomationRun,
AutomationRunOutputSnapshot
} from '../../shared/automations-types'
import type { AutomationRunTargetResult } from './run-target-resolution'
const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024
export type HeadlessAutomationDispatchLaunch = {
workspaceId: string
workspaceDisplayName?: string | null
terminalSessionId: string | null
completion?: Promise<{
status: 'completed' | 'dispatch_failed'
outputSnapshot?: AutomationRunOutputSnapshot | null
error?: string | null
}>
}
export type HeadlessAutomationDispatcher = (request: {
automation: Automation
run: AutomationRun
target: Extract<AutomationRunTargetResult, { ok: true }>
}) => Promise<HeadlessAutomationDispatchLaunch>
export function createHeadlessAutomationOutputSnapshotBuffer(): {
append: (chunk: string) => void
snapshot: () => AutomationRunOutputSnapshot | null
} {
const chunks: string[] = []
let totalChars = 0
let truncated = false
return {
append(chunk): void {
if (!chunk) {
return
}
chunks.push(chunk)
totalChars += chunk.length
let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS
while (overflowChars > 0 && chunks.length > 0) {
const firstChunk = chunks[0]!
if (firstChunk.length <= overflowChars) {
chunks.shift()
totalChars -= firstChunk.length
overflowChars -= firstChunk.length
truncated = true
continue
}
chunks[0] = firstChunk.slice(overflowChars)
totalChars -= overflowChars
truncated = true
overflowChars = 0
}
},
snapshot(): AutomationRunOutputSnapshot | null {
const content = chunks.join('').trim()
if (!content) {
return null
}
return {
format: 'plain_text',
content,
capturedAt: Date.now(),
truncated
}
}
}
}
@@ -0,0 +1,99 @@
import type { Store } from '../persistence'
import type { Automation } from '../../shared/automations-types'
import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup, Repo } from '../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
export type AutomationRunTargetResult =
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
| { ok: false; error: string }
type AutomationRunTargetOptions = {
allowRemoteHostScheduling?: boolean
}
function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null {
if (automation.workspaceMode === 'existing') {
const parsed = automation.workspaceId
? splitWorktreeIdForFilesystem(automation.workspaceId)
: null
return parsed?.worktreePath ?? null
}
return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null
}
export function resolveAutomationRunTarget(
store: Store,
automation: Automation,
options: AutomationRunTargetOptions = {}
): AutomationRunTargetResult {
const context = automation.runContext ?? null
if (!context) {
const repo = store.getRepo(getAutomationLegacyRepoId(automation))
const cwd = getLegacyPrecheckCwd(store, automation)
if (!repo || !cwd) {
return { ok: false, error: 'Automation run target is no longer available.' }
}
return { ok: true, cwd, repo }
}
const parsedHost = parseExecutionHostId(context.hostId)
if (
parsedHost?.kind === 'runtime' &&
(!options.allowRemoteHostScheduling || automation.schedulerOwner !== 'remote_host_service')
) {
return {
ok: false,
error:
'Remote-server automation scheduling is not available from this Orca client yet. Run this automation on the remote server or update Orca when durable remote scheduling is available.'
}
}
const setup = store
.getProjectHostSetups()
.find((candidate) => candidate.id === context.projectHostSetupId)
if (!setup) {
return {
ok: false,
error: 'Project is not set up on the selected automation host anymore.'
}
}
if (setup.setupState !== 'ready') {
return {
ok: false,
error: `Project setup on the selected automation host is ${setup.setupState}.`
}
}
if (
setup.projectId !== context.projectId ||
setup.hostId !== context.hostId ||
setup.repoId !== context.repoId
) {
return {
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'
}
}
const repo = store.getRepo(context.repoId)
if (!repo) {
return {
ok: false,
error: 'Repository for the selected automation host is no longer available.'
}
}
if (getRepoExecutionHostId(repo) !== context.hostId) {
return {
ok: false,
error: 'Repository is no longer attached to the selected automation host.'
}
}
if (repo.path !== setup.path || context.path !== setup.path) {
return {
ok: false,
error: 'Project path for the selected automation host has changed.'
}
}
return { ok: true, cwd: setup.path, repo, setup }
}
@@ -0,0 +1,99 @@
import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
function createUnavailableAutomationUsage(
collectedAt: number,
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage {
return {
status: 'unavailable',
provider,
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
}
}
function getAutomationUsageProvider(
automation: Automation | undefined
): AutomationRunUsage['provider'] {
if (automation?.agentId === 'codex') {
return 'codex'
}
if (automation?.agentId === 'claude') {
return 'claude'
}
return null
}
export async function collectAutomationRunUsage({
automation,
run,
claudeUsage,
codexUsage
}: {
automation: Automation | undefined
run: AutomationRun
claudeUsage: ClaudeUsageStore | null
codexUsage: CodexUsageStore | null
}): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage =>
createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage)
if (!automation || run.status !== 'completed') {
return unavailable(
getAutomationUsageProvider(automation),
'run_not_finished',
'Usage is only collected for completed automation runs.'
)
}
if (automation.executionTargetType === 'ssh') {
return unavailable(
getAutomationUsageProvider(automation),
'remote_usage_unavailable',
'Remote automation usage is not available from local usage logs.'
)
}
if (automation.agentId === 'claude') {
if (!claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
if (automation.agentId === 'codex') {
if (!codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
}
@@ -105,6 +105,46 @@ describe('AutomationService prechecks', () => {
})
})
it('does not run scheduled prechecks when the selected host setup is stale', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo({ path: '/repo/current' }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Conditional check',
prompt: 'Check the repo',
precheck: {
command: 'test -f ready',
timeoutSeconds: 30
},
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: '/repo/old'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
const service = new AutomationService(store, { tickMs: 60_000 })
const result = await service.runPrecheck(automation.id, run.id)
expect(result).toMatchObject({
command: 'test -f ready',
exitCode: null,
error: 'Project path for the selected automation host has changed.'
})
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
})
it('does not run prechecks for manual dispatches', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
@@ -129,4 +169,65 @@ describe('AutomationService prechecks', () => {
await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull()
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
})
it('honors scheduled prechecks before headless dispatch', async () => {
vi.setSystemTime(new Date('2026-05-12T08:59:00Z'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Conditional remote check',
prompt: 'Check the repo',
precheck: {
command: 'test -f ready',
timeoutSeconds: 30
},
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
})
runAutomationPrecheckMock.mockResolvedValue({
command: 'test -f ready',
exitCode: 1,
timedOut: false,
durationMs: 5,
stdout: '',
stderr: 'missing',
stdoutTruncated: false,
stderrTruncated: false,
error: null,
startedAt: Date.now(),
completedAt: Date.now()
})
const headlessDispatcher = vi.fn()
const service = new AutomationService(store, {
tickMs: 60_000,
allowRemoteHostScheduling: true,
headlessDispatcher
})
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
const requestHeadlessDispatch = (
service as unknown as {
requestHeadlessDispatch: (
automationArg: typeof automation,
runArg: typeof run,
targetArg: { ok: true; cwd: string; repo: Repo }
) => Promise<unknown>
}
).requestHeadlessDispatch.bind(service)
await requestHeadlessDispatch(automation, run, {
ok: true,
cwd: '/repo',
repo: store.getRepo('r1')!
})
expect(headlessDispatcher).not.toHaveBeenCalled()
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'skipped_precheck',
error: 'Precheck exited with code 1.'
})
})
})
+222
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import type { Repo } from '../../shared/types'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { AutomationService } from './service'
const testState = { dir: '' }
@@ -124,6 +125,227 @@ describe('AutomationService', () => {
)
})
it('skips dispatch when the selected project host setup is gone', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Manual check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'local',
projectHostSetupId: 'missing-setup',
repoId: 'r1',
path: '/repo'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('skipped_unavailable')
expect(run.error).toBe('Project is not set up on the selected automation host anymore.')
expect(send).not.toHaveBeenCalled()
})
it('skips dispatch when the saved project host setup path is stale', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo({ path: '/repo/current' }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Manual check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: '/repo/old'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('skipped_unavailable')
expect(run.error).toBe('Project path for the selected automation host has changed.')
expect(send).not.toHaveBeenCalled()
})
it('skips runtime-owned automations before desktop renderer dispatch', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Remote check',
prompt: 'Check the remote repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: runtimeHostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: setup.path
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('skipped_unavailable')
expect(run.error).toContain('Remote-server automation scheduling is not available')
expect(send).not.toHaveBeenCalled()
})
it('dispatches remote-host scheduled automations when service runs in serve mode', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Remote check',
prompt: 'Check the remote repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: runtimeHostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: setup.path
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, {
tickMs: 60_000,
allowRemoteHostScheduling: true
})
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('dispatching')
expect(send).toHaveBeenCalledWith(
'automations:dispatchRequested',
expect.objectContaining({
automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }),
run: expect.objectContaining({ id: run.id, status: 'dispatching' })
})
)
})
it('dispatches remote-host automations headlessly when no renderer is available', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Remote check',
prompt: 'Check the remote repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: runtimeHostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: setup.path
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const service = new AutomationService(store, {
tickMs: 60_000,
allowRemoteHostScheduling: true,
headlessDispatcher: vi.fn().mockResolvedValue({
workspaceId: 'remote-wt-1',
workspaceDisplayName: 'Remote automation',
terminalSessionId: 'remote-tab-1',
completion: Promise.resolve({
status: 'completed',
outputSnapshot: {
format: 'plain_text',
content: 'Done.',
capturedAt: Date.now(),
truncated: false
},
error: null
})
})
})
const run = await service.runNow(automation.id)
expect(run.status).toBe('dispatched')
expect(run.workspaceId).toBe('remote-wt-1')
expect(run.workspaceDisplayName).toBe('Remote automation')
expect(run.terminalSessionId).toBe('remote-tab-1')
await vi.waitFor(() =>
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'completed',
workspaceId: 'remote-wt-1',
terminalSessionId: 'remote-tab-1',
outputSnapshot: expect.objectContaining({ content: 'Done.' })
})
)
})
it('attaches provider usage when a completed run can be attributed', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
+110 -97
View File
@@ -6,13 +6,18 @@ import type {
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun,
AutomationRunStatus,
AutomationRunUsage
AutomationRunStatus
} from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { runAutomationPrecheck } from './precheck-runner'
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
import { collectAutomationRunUsage } from './run-usage-collection'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../shared/automation-precheck'
const DEFAULT_TICK_MS = 60 * 1000
@@ -25,15 +30,25 @@ export class AutomationService {
private evaluating = false
private readonly claudeUsage: ClaudeUsageStore | null
private readonly codexUsage: CodexUsageStore | null
private readonly allowRemoteHostScheduling: boolean
private readonly headlessDispatcher: HeadlessAutomationDispatcher | null
constructor(
store: Store,
opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {}
opts: {
tickMs?: number
claudeUsage?: ClaudeUsageStore
codexUsage?: CodexUsageStore
allowRemoteHostScheduling?: boolean
headlessDispatcher?: HeadlessAutomationDispatcher
} = {}
) {
this.store = store
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
this.claudeUsage = opts.claudeUsage ?? null
this.codexUsage = opts.codexUsage ?? null
this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false
this.headlessDispatcher = opts.headlessDispatcher ?? null
}
setWebContents(webContents: WebContents | null): void {
@@ -87,8 +102,10 @@ export class AutomationService {
if (run.trigger !== 'scheduled' || !automation.precheck) {
return null
}
const cwd = this.getPrecheckCwd(automation)
if (!cwd) {
const target = resolveAutomationRunTarget(this.store, automation, {
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
if (!target.ok) {
return {
command: automation.precheck.command,
exitCode: null,
@@ -98,7 +115,7 @@ export class AutomationService {
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
error: 'Automation precheck target is no longer available.',
error: target.error,
startedAt: Date.now(),
completedAt: Date.now()
}
@@ -107,8 +124,8 @@ export class AutomationService {
precheck: automation.precheck,
target:
automation.executionTargetType === 'ssh'
? { type: 'ssh', cwd, connectionId: automation.executionTargetId }
: { type: 'local', cwd }
? { type: 'ssh', cwd: target.cwd, connectionId: automation.executionTargetId }
: { type: 'local', cwd: target.cwd }
})
}
@@ -124,7 +141,12 @@ export class AutomationService {
if (run.usage) {
return run
}
const usage = await this.collectRunUsage(run)
const usage = await collectAutomationRunUsage({
automation: this.store.listAutomations().find((entry) => entry.id === run.automationId),
run,
claudeUsage: this.claudeUsage,
codexUsage: this.codexUsage
})
return this.store.updateAutomationRun({
runId: run.id,
status: run.status,
@@ -135,83 +157,6 @@ export class AutomationService {
})
}
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
const collectedAt = Date.now()
const unavailable = (
provider: AutomationRunUsage['provider'],
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider,
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!automation || run.status !== 'completed') {
return unavailable(
automation?.agentId === 'codex'
? 'codex'
: automation?.agentId === 'claude'
? 'claude'
: null,
'run_not_finished',
'Usage is only collected for completed automation runs.'
)
}
if (automation.executionTargetType === 'ssh') {
return unavailable(
automation.agentId === 'codex'
? 'codex'
: automation.agentId === 'claude'
? 'claude'
: null,
'remote_usage_unavailable',
'Remote automation usage is not available from local usage logs.'
)
}
if (automation.agentId === 'claude') {
if (!this.claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return this.claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
if (automation.agentId === 'codex') {
if (!this.codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return this.codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
}
return unavailable(
null,
'provider_unsupported',
'This agent does not report usage to Orca yet.'
)
}
private async evaluateDueRuns(): Promise<void> {
if (this.evaluating) {
return
@@ -230,16 +175,6 @@ export class AutomationService {
}
}
private getPrecheckCwd(automation: Automation): string | null {
if (automation.workspaceMode === 'existing') {
const parsed = automation.workspaceId
? splitWorktreeIdForFilesystem(automation.workspaceId)
: null
return parsed?.worktreePath ?? null
}
return this.store.getRepo(automation.projectId)?.path ?? null
}
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
if (scheduledFor === null) {
@@ -267,8 +202,22 @@ export class AutomationService {
automation: Automation,
run: AutomationRun
): Promise<AutomationRun> {
const target = resolveAutomationRunTarget(this.store, automation, {
allowRemoteHostScheduling: this.allowRemoteHostScheduling
})
if (!target.ok) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: target.error
})
}
const webContents = this.webContents
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
if (this.headlessDispatcher) {
return await this.requestHeadlessDispatch(automation, run, target)
}
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
@@ -286,6 +235,70 @@ export class AutomationService {
webContents.send('automations:dispatchRequested', payload)
return updated
}
private async requestHeadlessDispatch(
automation: Automation,
run: AutomationRun,
target: Extract<AutomationRunTargetResult, { ok: true }>
): Promise<AutomationRun> {
const precheckResult =
run.trigger === 'scheduled' && automation.precheck
? await this.runPrecheck(automation.id, run.id)
: null
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_precheck',
workspaceId: automation.workspaceId,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
}
try {
const launch = await this.headlessDispatcher!({ automation, run, target })
const updated = this.store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
error: null
})
if (launch.completion) {
void launch.completion
.then((completion) =>
this.markDispatchResult({
runId: run.id,
status: completion.status,
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
})
)
.catch((error) =>
this.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
error: error instanceof Error ? error.message : String(error)
})
)
}
return updated
} catch (error) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'dispatch_failed',
workspaceId: automation.workspaceId,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
function isFinalRunStatus(status: AutomationRunStatus): boolean {
+41
View File
@@ -420,6 +420,47 @@ describe('setupGuestShortcutForwarding', () => {
guestOffMock = vi.fn()
})
it('commits Ctrl+Tab switching from focused guest pages on generic release events', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer()
})
const ctrlTabInput = { code: 'Tab', key: 'Tab', control: true, meta: false }
const releaseInputs: Partial<Electron.Input>[] = [
{
type: 'keyUp',
code: 'Control',
key: 'Control',
control: false,
meta: false
},
{
type: 'keyUp',
code: 'Tab',
key: 'Tab',
control: false,
meta: false
}
]
for (const releaseInput of releaseInputs) {
rendererSendMock.mockClear()
const keyDownPreventDefault = triggerBeforeInput(ctrlTabInput)
const tabReleasePreventDefault = triggerBeforeInput({ ...ctrlTabInput, type: 'keyUp' })
const keyUpPreventDefault = triggerBeforeInput(releaseInput)
expect(keyDownPreventDefault).toHaveBeenCalledTimes(1)
expect(tabReleasePreventDefault).not.toHaveBeenCalled()
expect(keyUpPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', {
shiftKey: false
})
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyUp')
}
})
it('forwards browser page zoom shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
+9 -11
View File
@@ -9,6 +9,7 @@ import {
redactKagiSessionToken
} from '../../shared/browser-url'
import {
isRecentTabSwitcherCommitRelease,
matchesRecentTabSwitcherChord,
resolveWindowShortcutAction
} from '../../shared/window-shortcut-policy'
@@ -51,10 +52,6 @@ export function resolveGuestMouseWheelZoomDirection(
return deltaY < 0 ? 'in' : 'out'
}
function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
export function setupGuestContextMenu(args: {
browserTabId: string
guest: Electron.WebContents
@@ -268,17 +265,18 @@ export function setupGuestShortcutForwarding(args: {
let ctrlTabSwitching = false
const handler = (event: Electron.Event, input: Electron.Input): void => {
const keybindings = getKeybindings?.()
if (matchesRecentTabSwitcherChord(input, process.platform, keybindings)) {
if (
input.type === 'keyDown' &&
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
) {
event.preventDefault()
if (input.type === 'keyDown') {
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
}
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
return
}
if (ctrlTabSwitching && isControlKeyRelease(input)) {
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
const renderer = resolveRenderer(browserTabId)
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyRefreshedToken,
isOauthTokenExpiring,
parseClaudeOauthBlob,
readRefreshToken,
refreshClaudeOauthCredentials
} from './oauth-refresh'
const { netFetchMock } = vi.hoisted(() => ({
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({
net: { fetch: netFetchMock },
session: { defaultSession: {} }
}))
vi.mock('../network/proxy-settings', () => ({
ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' })
}))
const NOW = 1_700_000_000_000
function credentials(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
claudeAiOauth: {
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresAt: NOW + 60 * 60 * 1000,
scopes: ['user:inference', 'user:profile'],
...overrides
}
})
}
describe('parseClaudeOauthBlob', () => {
it('returns the oauth block', () => {
expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access')
})
it('returns null for non-JSON or missing block', () => {
expect(parseClaudeOauthBlob('not json')).toBeNull()
expect(parseClaudeOauthBlob('{}')).toBeNull()
expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull()
})
})
describe('readRefreshToken', () => {
it('reads a present token', () => {
expect(readRefreshToken(credentials())).toBe('old-refresh')
})
it('returns null for blank or missing tokens', () => {
expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull()
expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull()
})
})
describe('isOauthTokenExpiring', () => {
it('is false when well within validity', () => {
expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false)
})
it('is true within the 5-minute buffer', () => {
expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true)
})
it('is true when already expired', () => {
expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true)
})
it('treats missing/non-numeric expiry as expiring', () => {
expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true)
expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true)
})
it('is false for credentials without an oauth block', () => {
expect(isOauthTokenExpiring('{}', NOW)).toBe(false)
})
})
describe('applyRefreshedToken', () => {
it('rotates access + refresh token and recomputes expiry', () => {
const updated = applyRefreshedToken(
credentials(),
{ access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' },
NOW
)
const oauth = parseClaudeOauthBlob(updated!)!
expect(oauth.accessToken).toBe('new-access')
expect(oauth.refreshToken).toBe('new-refresh')
expect(oauth.expiresAt).toBe(NOW + 3600 * 1000)
})
it('keeps the existing refresh token when the server does not rotate it', () => {
const updated = applyRefreshedToken(
credentials(),
{ access_token: 'new-access', expires_in: 3600 },
NOW
)
expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh')
})
it('preserves unrelated top-level fields', () => {
const raw = JSON.stringify({
claudeAiOauth: { accessToken: 'a', refreshToken: 'r' },
somethingElse: { keep: true }
})
const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW)
expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true })
})
it('splits scope string into scopes array', () => {
const updated = applyRefreshedToken(
credentials(),
{ access_token: 'b', scope: 'user:inference user:profile' },
NOW
)
expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile'])
})
it('returns null when the response lacks an access token', () => {
expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull()
expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull()
})
})
describe('refreshClaudeOauthCredentials', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('returns null without a refresh token (no network call)', async () => {
const result = await refreshClaudeOauthCredentials(
credentials({ refreshToken: undefined }),
NOW
)
expect(result).toBeNull()
expect(netFetchMock).not.toHaveBeenCalled()
})
it('posts a form-urlencoded refresh grant and persists the rotation', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'fresh-access',
expires_in: 3600,
refresh_token: 'fresh-refresh'
})
})
const result = await refreshClaudeOauthCredentials(credentials(), NOW)
expect(netFetchMock).toHaveBeenCalledTimes(1)
const [url, init] = netFetchMock.mock.calls[0]
expect(url).toBe('https://platform.claude.com/v1/oauth/token')
expect(init.method).toBe('POST')
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
const body = new URLSearchParams(init.body)
expect(body.get('grant_type')).toBe('refresh_token')
expect(body.get('refresh_token')).toBe('old-refresh')
expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e')
const oauth = parseClaudeOauthBlob(result!)!
expect(oauth.accessToken).toBe('fresh-access')
expect(oauth.refreshToken).toBe('fresh-refresh')
})
it('returns null on a non-ok response and logs the status for diagnosability', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) })
expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('429'))
warn.mockRestore()
})
it('returns null when the request throws (never rejects)', async () => {
netFetchMock.mockRejectedValue(new Error('network down'))
await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull()
})
})
+174
View File
@@ -0,0 +1,174 @@
import { net, session } from 'electron'
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
// Why: the OAuth client id and token endpoint are the public Claude Code
// values, verified against the installed `claude` binary (2.1.177) and the
// claude-swap reference tool. Orca owns the refresh so a single-use refresh
// token is rotated and persisted atomically, instead of being scraped back
// after the CLI rotates it (the lossy path that strands stale tokens).
const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The
// CLI uses the same 5-minute skew for its own refresh decision.
const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000
const REFRESH_TIMEOUT_MS = 10_000
type ClaudeOauthBlob = {
accessToken?: unknown
refreshToken?: unknown
expiresAt?: unknown
scopes?: unknown
[key: string]: unknown
}
type ClaudeCredentials = {
claudeAiOauth?: ClaudeOauthBlob
[key: string]: unknown
}
type TokenEndpointResponse = {
access_token?: unknown
expires_in?: unknown
refresh_token?: unknown
scope?: unknown
}
/**
* Parse the `claudeAiOauth` object from a credentials JSON string.
* Returns null when the string is not parseable or lacks the OAuth block.
*/
export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null {
try {
const parsed = JSON.parse(credentialsJson) as ClaudeCredentials
const oauth = parsed?.claudeAiOauth
return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null
} catch {
return null
}
}
/** Read a stored refresh token, or null when absent/blank. */
export function readRefreshToken(credentialsJson: string): string | null {
const oauth = parseClaudeOauthBlob(credentialsJson)
const token = oauth?.refreshToken
return typeof token === 'string' && token.trim() !== '' ? token.trim() : null
}
/**
* Whether the stored access token is expired or within the refresh buffer.
*
* A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob
* with no usable expiry metadata still gets a proactive refresh attempt rather
* than being trusted indefinitely. `now` is injectable for tests.
*/
export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean {
const oauth = parseClaudeOauthBlob(credentialsJson)
if (!oauth) {
return false
}
const expiresAt = oauth.expiresAt
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
return true
}
return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt
}
/**
* Merge a token-endpoint response into the stored credentials, returning the
* updated credentials JSON. Preserves every field the caller already had
* (including the refresh token when the server does not rotate it) and only
* overwrites what the response provides. Returns null on malformed input.
*/
export function applyRefreshedToken(
credentialsJson: string,
response: TokenEndpointResponse,
now: number = Date.now()
): string | null {
let parsed: ClaudeCredentials
try {
parsed = JSON.parse(credentialsJson) as ClaudeCredentials
} catch {
return null
}
const accessToken = response.access_token
if (typeof accessToken !== 'string' || accessToken.trim() === '') {
return null
}
const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth }
oauth.accessToken = accessToken
if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) {
oauth.expiresAt = now + response.expires_in * 1000
}
// Rotation: keep the existing refresh token unless the server issued a new
// one. Single-use refresh tokens make persisting the rotated value the whole
// point of owning refresh.
if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') {
oauth.refreshToken = response.refresh_token
}
if (typeof response.scope === 'string' && response.scope.trim() !== '') {
oauth.scopes = response.scope.split(' ')
}
parsed.claudeAiOauth = oauth
return JSON.stringify(parsed)
}
/**
* Refresh the OAuth token for a stored credentials blob.
*
* Returns the updated credentials JSON (with the rotated refresh token and new
* access token) on success, or null on any failure. Never throws — callers
* treat null as "keep the existing credentials", so a transient network error
* is never worse than today's behavior.
*/
export async function refreshClaudeOauthCredentials(
credentialsJson: string,
now: number = Date.now()
): Promise<string | null> {
const refreshToken = readRefreshToken(credentialsJson)
if (!refreshToken) {
return null
}
await ensureElectronProxyFromEnvironment({
proxySession: session.defaultSession,
probeUrl: OAUTH_TOKEN_URL
}).catch(() => {})
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
try {
// Why: the `claude` CLI posts grant_type=refresh_token as
// application/x-www-form-urlencoded with the public client id. net.fetch
// routes through Chromium's stack so the env proxy bridge above applies.
const res = await net.fetch(OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID
}).toString(),
signal: controller.signal
})
if (!res.ok) {
// Why: surface the status (never the token) so a throttle (429) or a
// dead refresh token (400/401 invalid_grant) is diagnosable in the
// field, instead of a silent null that looks identical to success.
// Callers keep the existing credentials on null — a transient 429 just
// means the still-valid token is reused until the next attempt.
console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`)
return null
}
const data = (await res.json()) as TokenEndpointResponse
return applyRefreshedToken(credentialsJson, data, now)
} catch (error) {
console.warn(
'[claude-oauth-refresh] token refresh request failed:',
error instanceof Error ? error.message : error
)
return null
} finally {
clearTimeout(timer)
}
}
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getDefaultSettings } from '../../shared/constants'
import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types'
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const testState = {
@@ -43,6 +44,15 @@ vi.mock('electron', () => ({
}
}))
// Why: these tests exercise materialize/read-back/snapshot logic, not the
// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token
// to "not expiring" so the proactive switch-in refresh never fires here and
// existing expectations hold; individual tests can override these mocks.
vi.mock('./oauth-refresh', () => ({
isOauthTokenExpiring: vi.fn(() => false),
refreshClaudeOauthCredentials: vi.fn(async () => null)
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
@@ -2872,7 +2882,7 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
})
it('does not clobber unverified live runtime credentials when switching accounts', async () => {
it('switches accounts without persisting unverified live runtime credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a')
const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b')
@@ -2912,15 +2922,16 @@ describe('ClaudeRuntimeAuthService', () => {
writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8')
settings.activeClaudeManagedAccountId = 'account-2'
await expect(service.syncForCurrentSelection()).rejects.toThrow(
'live Claude terminal has unverified refreshed auth'
)
await service.syncForCurrentSelection()
} finally {
markClaudePtyExited('live-claude-pty')
}
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(unverifiedLiveCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
if (process.platform === 'darwin') {
expect(testState.scopedKeychainCredentials).toBe(account2Credentials)
}
})
it('routes refreshed Claude credentials to the matching managed account', async () => {
@@ -3553,4 +3564,181 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials)
})
it('leaves host system-default credentials untouched before launch', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000)
writeFileSync(runtimeCredentialsPath, expired, 'utf-8')
testState.scopedKeychainCredentials = expired
testState.legacyKeychainCredentials = expired
const settings = createSettings({
activeClaudeManagedAccountId: null
})
const store = createStore(settings)
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(
createClaudeCredentialsJson('system@example.com', 'system-refreshed')
)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
const preparation = await service.prepareForClaudeLaunch()
expect(isOauthTokenExpiring).not.toHaveBeenCalled()
expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled()
expect(preparation.provenance).toBe('system')
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired)
expect(testState.scopedKeychainCredentials).toBe(expired)
expect(testState.legacyKeychainCredentials).toBe(expired)
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
})
it('proactively refreshes and persists an expiring account on switch-in', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000)
const account1Refreshed = createClaudeCredentialsJson(
'one@example.com',
'one-refreshed',
null,
9_999_999_999_999
)
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
account1Stale
)
// Start on the system default (no active managed account), then switch in.
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
],
activeClaudeManagedAccountId: null
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
// Now switch into account-1: token is expiring, so the service must refresh
// and persist the rotation before materializing.
vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed)
store.updateSettings({ activeClaudeManagedAccountId: 'account-1' })
await service.syncForCurrentSelection()
expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale)
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed)
})
it('refreshes the active account with an expired token when no Claude PTY is live', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000)
const refreshedCreds = createClaudeCredentialsJson(
'one@example.com',
'one-refreshed',
null,
9_999_999_999_999
)
const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired)
// account-1 is ALREADY the active account (seeded), so this is a re-sync of
// the active account, not a switch-in — the path that was previously missed.
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
expect(refreshClaudeOauthCredentials).toHaveBeenCalled()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds)
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
})
it('does not refresh the active account while a Claude PTY is live', async () => {
const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000)
const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(
createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999)
)
const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate')
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
markClaudePtySpawned('pty-live-1')
try {
await service.syncForCurrentSelection()
// A live Claude owns the credentials; refreshing here would race its
// rotation, so the proactive refresh must be skipped entirely.
expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled()
} finally {
markClaudePtyExited('pty-live-1')
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
}
})
it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
// Same expiry on both sides (cold start), but the runtime refresh token has
// rotated — proof the CLI refreshed. Must be read back into managed storage.
const managedCredentials = createClaudeCredentialsJson(
'one@example.com',
'one-old',
null,
3_000
)
const runtimeRotated = `${JSON.stringify({
claudeAiOauth: {
email: 'one@example.com',
accessToken: 'one-rotated',
refreshToken: 'one-rotated-refresh',
expiresAt: 3_000
}
})}\n`
writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8')
const managedAuthPath1 = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@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()
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated)
})
})
@@ -18,6 +18,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
import { hasLiveClaudePtys } from './live-pty-gate'
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
import { ClaudeRuntimePathResolver } from './runtime-paths'
import {
deleteActiveClaudeKeychainCredentialsStrict,
@@ -211,8 +212,12 @@ export class ClaudeRuntimeAuthService {
outgoingReadBackResult.runtimeCredentialsJson
)
} else {
throw new Error(
'Claude account switch paused because a live Claude terminal has unverified refreshed auth.'
// Why: Claude's runtime credential blob can lack enough identity
// proof to attribute a live-session refresh. Do not persist that
// unverified blob, but also do not block the user from moving new
// terminals to the selected managed account.
console.warn(
'[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts'
)
}
}
@@ -398,6 +403,25 @@ export class ClaudeRuntimeAuthService {
if (this.lastSyncedAccountId !== activeAccount.id) {
this.skipNextReadBackForAccountId = null
}
// Why: own the OAuth refresh whenever no live `claude` owns these
// credentials — both switching into an account and re-syncing the active
// account with an expired token. A single-use refresh token is rotated and
// persisted to managed storage atomically before we materialize it, so the
// runtime never gets a stale token that fails with invalid_grant. Skipped
// entirely while a Claude PTY is live: that process owns the credentials
// and refreshing here would race its own rotation (double-rotation
// invalidates one copy) — the read-back above preserves its refresh instead.
if (!hasLiveClaudePtys()) {
const refreshed = await this.refreshManagedAccountTokenIfNeeded(
activeAccount,
credentialsJson
)
if (refreshed) {
credentialsJson = refreshed
}
}
const paths = this.pathResolver.getRuntimePaths()
this.writeRuntimeCredentials(credentialsJson)
if (process.platform === 'darwin') {
@@ -481,15 +505,28 @@ export class ClaudeRuntimeAuthService {
continue
}
// 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.
// credentials are a fresh CLI refresh or stale state. Adopt when the
// token expiry proves runtime is newer, OR the refresh token rotated
// and runtime is not provably older. A rotated refresh token with
// equal/missing expiry is a genuine CLI refresh we'd otherwise drop
// (stranding a stale managed token); but if expiry proves runtime is
// older, managed already holds the newer token (e.g. a prior read-back
// or proactive refresh), so reject it.
if (this.lastWrittenCredentialsJson === null) {
if (
!this.runtimeCredentialsAreFresher(
const fresher = this.runtimeCredentialsAreFresher(
runtimeContents.credentialsJson,
match.managedCredentialsJson
)
const refreshTokenRotated =
this.compareRefreshTokens(
runtimeContents.credentialsJson,
match.managedCredentialsJson
)
) {
) === 'different'
const older = this.runtimeCredentialsAreOlder(
runtimeContents.credentialsJson,
match.managedCredentialsJson
)
if (!fresher && !(refreshTokenRotated && !older)) {
continue
}
} else if (
@@ -1000,6 +1037,37 @@ export class ClaudeRuntimeAuthService {
writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson)
}
/**
* Proactively refresh an account's OAuth token and persist the rotation to
* managed storage. Returns the refreshed credentials JSON when a rotation was
* stored, or null when no refresh happened (token still valid, no refresh
* token, or the network call failed — in which case the caller keeps the
* existing credentials, never worse than before).
*
* Caller guarantees this account is not the live/active one and runs inside
* the serialized mutation queue, so a single-use refresh token can't be
* rotated concurrently.
*/
private async refreshManagedAccountTokenIfNeeded(
account: ClaudeManagedAccount,
credentialsJson: string
): Promise<string | null> {
if (!isOauthTokenExpiring(credentialsJson)) {
return null
}
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) {
return null
}
try {
await this.writeManagedCredentials(account, refreshed)
} catch (error) {
console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error)
return null
}
return refreshed
}
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
const managedAuthPath = this.getOwnedManagedAuthPath(account)
if (!managedAuthPath) {
+2 -2
View File
@@ -803,7 +803,7 @@ describe('ClaudeAccountService credential capture', () => {
}
const runtimeAuth = {
syncForCurrentSelection: vi.fn(async () => {
throw new Error('unverified live auth')
throw new Error('runtime sync failed')
}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
@@ -817,7 +817,7 @@ describe('ClaudeAccountService credential capture', () => {
runtimeAuth as never
)
await expect(service.selectAccount('account-2')).rejects.toThrow('unverified live auth')
await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed')
expect(settings.activeClaudeManagedAccountId).toBe('account-1')
expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
@@ -136,6 +137,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
@@ -135,6 +135,10 @@ export class CodexRuntimeHomeService {
return this.getRuntimeHomePath()
}
getHostRuntimeHomePath(): string {
return this.getRuntimeHomePath()
}
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return null
+2
View File
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
@@ -140,6 +141,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
@@ -1,12 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent, Stats } from 'node:fs'
import type * as FsPromises from 'fs/promises'
import { join } from 'path'
const { readdirMock, statMock } = vi.hoisted(() => ({
readdirMock: vi.fn<(dirPath: string) => Promise<Dirent[]>>(),
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
}))
const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted(
() => ({
getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(),
readdirMock: vi.fn<(dirPath: string) => Promise<Dirent[]>>(),
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
})
)
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
@@ -29,7 +32,8 @@ vi.mock('../codex/codex-home-paths', () => ({
}))
vi.mock('../codex/codex-session-bridge', () => ({
getLegacyCopiedCodexSessionBridgeScanPreference: () => null
getLegacyCopiedCodexSessionBridgeScanPreference:
getLegacyCopiedCodexSessionBridgeScanPreferenceMock
}))
function dirent(name: string, kind: 'directory' | 'file'): Dirent {
@@ -45,6 +49,13 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) =>
)
describe('listCodexSessionFiles large directories', () => {
beforeEach(() => {
getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset()
getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null)
readdirMock.mockReset()
statMock.mockReset()
})
it('keeps nested session scans past the JavaScript spread-argument limit', async () => {
readdirMock.mockImplementation(async (dirPath) => {
if (dirPath === RUNTIME_SESSIONS_ROOT) {
@@ -69,5 +80,6 @@ describe('listCodexSessionFiles large directories', () => {
const { listCodexSessionFiles } = await import('./scanner')
await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT)
expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled()
})
})
+59 -21
View File
@@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */
import { basename, join, win32, posix } from 'path'
import { createReadStream } from 'fs'
import { createReadStream, existsSync } from 'fs'
import { realpath, readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import type { Repo } from '../../shared/types'
@@ -55,6 +55,7 @@ type CodexUsageDeltaResolution =
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
const YIELD_EVERY_FILES = 10
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
function ensureNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
@@ -88,17 +89,24 @@ async function canonicalizePath(pathValue: string): Promise<string> {
}
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setImmediate(resolve))
}
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
async function walkJsonlFiles(
dirPath: string,
progress: { entriesVisited: number } = { entriesVisited: 0 }
): Promise<string[]> {
const entries = await readdir(dirPath, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
progress.entriesVisited += 1
if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath))
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress))
continue
}
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
@@ -132,6 +140,10 @@ export function getCodexSessionDirectories(): string[] {
)
}
function hasLegacyCopiedSessionBridgeMarkers(): boolean {
return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies'))
}
export async function listCodexSessionFiles(): Promise<string[]> {
const files: string[] = []
for (const dirPath of getCodexSessionDirectories()) {
@@ -141,29 +153,37 @@ export async function listCodexSessionFiles(): Promise<string[]> {
// Missing or unreadable history in one home should not hide the other.
}
}
return dedupeCodexSessionFileAliases(files)
return dedupeCodexSessionFileAliases(files, hasLegacyCopiedSessionBridgeMarkers())
}
async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]> {
async function dedupeCodexSessionFileAliases(
files: string[],
hasLegacyBridgeMarkers: boolean
): Promise<string[]> {
const excludedAliases = new Set<string>()
for (const filePath of files) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if (!legacyCopyBridge) {
continue
}
if (legacyCopyBridge.sourceSkipBytes !== null) {
continue
}
excludedAliases.add(
await getPhysicalFileAliasKey(
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
if (hasLegacyBridgeMarkers) {
for (const [index, filePath] of files.entries()) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
if (!legacyCopyBridge) {
continue
}
if (legacyCopyBridge.sourceSkipBytes !== null) {
continue
}
excludedAliases.add(
await getPhysicalFileAliasKey(
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
)
)
)
}
}
const seenAliases = new Set<string>()
const uniqueFiles: string[] = []
for (const filePath of [...new Set(files)].sort()) {
for (const [index, filePath] of [...new Set(files)].sort().entries()) {
const aliasKey = await getCodexSessionFileAliasKey(filePath)
if (excludedAliases.has(aliasKey)) {
continue
@@ -173,6 +193,9 @@ async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]>
}
seenAliases.add(aliasKey)
uniqueFiles.push(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
}
return uniqueFiles
}
@@ -191,8 +214,14 @@ async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
return `path:${await canonicalizePath(filePath)}`
}
function getLegacySourceSkipBytesByPath(files: string[]): Map<string, number> {
function getLegacySourceSkipBytesByPath(
files: string[],
hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers()
): Map<string, number> {
const sourceSkipBytesByPath = new Map<string, number>()
if (!hasLegacyBridgeMarkers) {
return sourceSkipBytesByPath
}
for (const filePath of files) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) {
@@ -734,7 +763,7 @@ function mergeSessions(
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
target.set(session.sessionId, cloneSessionForMerge(session))
continue
}
@@ -809,6 +838,15 @@ function mergeSessions(
}
}
function cloneSessionForMerge(session: CodexUsageSession): CodexUsageSession {
return {
...session,
locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })),
modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })),
locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry }))
}
}
function mergeDailyAggregates(
target: Map<string, CodexUsageDailyAggregate>,
dailyAggregates: CodexUsageDailyAggregate[]
+123 -3
View File
@@ -1,6 +1,15 @@
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CodexUsagePersistedState } from './types'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as Fs from 'fs'
import type {
CodexUsageDailyAggregate,
CodexUsagePersistedFile,
CodexUsagePersistedState,
CodexUsageSession
} from './types'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
@@ -12,11 +21,54 @@ vi.mock('electron', () => ({
}
}))
import { CodexUsageStore, normalizePersistedState } from './store'
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return {
...actual,
writeFileSync: vi.fn(actual.writeFileSync)
}
})
vi.mock('./scanner', () => ({
createWorktreeRefs: vi.fn(() => []),
scanCodexUsageFiles: vi.fn()
}))
import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store'
import { scanCodexUsageFiles } from './scanner'
type ScanResult = {
processedFiles: CodexUsagePersistedFile[]
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
function createEmptyScanResult(): ScanResult {
return {
processedFiles: [],
sessions: [],
dailyAggregates: []
}
}
function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
const store = new CodexUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
@@ -39,11 +91,79 @@ function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUs
}
describe('CodexUsageStore', () => {
let tempUserData: string
beforeEach(() => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initCodexUsagePath()
vi.mocked(writeFileSync).mockClear()
vi.mocked(scanCodexUsageFiles).mockReset()
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(tempUserData, { recursive: true, force: true })
})
it('persists a successful refresh with one compact disk write', async () => {
const store = createStoreWithState({
schemaVersion: 3,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.refresh(true)
expect(writeFileSync).toHaveBeenCalledTimes(1)
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
expect(persistedJson).not.toContain('\n')
expect(JSON.parse(persistedJson).scanState).toMatchObject({
enabled: true,
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('keeps scan start visible in memory while scan-start persistence is skipped', async () => {
const pendingScan = createDeferred<ScanResult>()
vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise)
const store = createStoreWithState({
schemaVersion: 3,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: 'previous failure'
}
})
const refreshPromise = store.refresh(true)
await Promise.resolve()
expect(store.getScanState()).toMatchObject({
isScanning: true,
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
expect(writeFileSync).not.toHaveBeenCalled()
pendingScan.resolve(createEmptyScanResult())
await refreshPromise
expect(store.getScanState().isScanning).toBe(false)
expect(writeFileSync).toHaveBeenCalledTimes(1)
})
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
const store = createStoreWithState({
sessions: [
+2 -2
View File
@@ -358,7 +358,7 @@ export class CodexUsageStore {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
renameSync(tmpFile, usageFile)
}
@@ -414,7 +414,7 @@ export class CodexUsageStore {
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why: start-only writes rewrite the full usage cache before scan results change.
this.scanPromise = (async () => {
try {
@@ -70,7 +70,7 @@ describe('daemon bundle staleness', () => {
{ mode: 0o600 }
)
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
} finally {
child.kill('SIGKILL')
}
@@ -101,7 +101,7 @@ describe('daemon bundle staleness', () => {
{ mode: 0o600 }
)
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(false)
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(false)
} finally {
child.kill('SIGKILL')
}
@@ -131,7 +131,7 @@ describe('daemon bundle staleness', () => {
{ mode: 0o600 }
)
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
} finally {
child.kill('SIGKILL')
}
@@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => {
const result = healthCheckDaemon(socketPath, tokenPath)
socket.emit('connect')
socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n'))
socket.emit(
'data',
Buffer.from(
'{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n'
)
)
await expect(result).resolves.toBe(true)
expect(socket.listenerCount('connect')).toBe(0)
+21
View File
@@ -74,15 +74,36 @@ describe('daemon health', () => {
})
it('passes when a daemon answers ping', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
} finally {
await server.shutdown()
}
})
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck: vi.fn(async () => {
throw new Error('stale node-pty helper')
}),
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
} finally {
await server.shutdown()
}
+76 -57
View File
@@ -1,8 +1,10 @@
/* oxlint-disable max-lines -- Why: pid validation shares process-identity
helpers with kill escalation so the SIGKILL safety checks stay co-located. */
import { execFileSync } from 'child_process'
import { execFile, execFileSync } from 'child_process'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { connect, type Socket } from 'net'
import { promisify } from 'util'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
import { encodeNdjson } from './ndjson'
import { getDaemonPidPath } from './daemon-spawner'
import {
@@ -135,12 +137,15 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
settle(false)
return
}
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
// Why: a protocol-live daemon with a stale cwd or node-pty helper
// will answer ping but cannot create terminals, so reuse must check
// the PTY spawn prerequisites too.
sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' }))
continue
}
if (message.id === 'health-1') {
settle(Boolean(message.ok))
settle(message.ok === true)
return
}
}
@@ -369,12 +374,50 @@ export function startTimeMatches(pid: number, expectedStartedAtMs: number | null
return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS
}
function isDaemonProcess(
const execFileAsync = promisify(execFile)
// Why: the only reliable command-line source on Windows is a CIM query, which
// costs a full powershell.exe spawn (300-800ms cold, worse under Defender).
// Async because the sync version measurably froze the Electron main thread at
// startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold).
// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can
// attribute startup cost to these checks.
async function queryWindowsProcessCommandLine(pid: number): Promise<string | null> {
const startedAt = performance.now()
try {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
],
{
encoding: 'utf8',
timeout: 3_000
}
)
return stdout
} catch {
return null
} finally {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic('daemon-pid-check', {
t: Math.round(performance.now()),
pid,
ms: Math.round(performance.now() - startedAt)
})
}
}
}
async function isDaemonProcess(
pid: number,
socketPath: string,
tokenPath: string,
startedAtMs: number | null
): boolean {
): Promise<boolean> {
try {
process.kill(pid, 0)
} catch {
@@ -382,30 +425,16 @@ function isDaemonProcess(
}
if (process.platform === 'win32') {
try {
const output = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
],
{
encoding: 'utf8',
timeout: 3_000
}
)
// Why: image names are too broad after PID reuse. Match the daemon entry
// plus the exact socket/token args so we only kill the daemon for this
// userData protocol endpoint.
return (
commandLineMatchesDaemon(output, socketPath, tokenPath) &&
startTimeMatches(pid, startedAtMs)
)
} catch {
const output = await queryWindowsProcessCommandLine(pid)
if (output === null) {
return false
}
// Why: image names are too broad after PID reuse. Match the daemon entry
// plus the exact socket/token args so we only kill the daemon for this
// userData protocol endpoint.
return (
commandLineMatchesDaemon(output, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
)
}
try {
@@ -429,25 +458,9 @@ function isDaemonProcess(
}
}
function getDaemonCommandLine(pid: number): string | null {
async function getDaemonCommandLine(pid: number): Promise<string | null> {
if (process.platform === 'win32') {
try {
return execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
],
{
encoding: 'utf8',
timeout: 3_000
}
)
} catch {
return null
}
return queryWindowsProcessCommandLine(pid)
}
try {
@@ -466,14 +479,14 @@ function getDaemonCommandLine(pid: number): string | null {
export type DaemonLaunchIdentity = 'match' | 'mismatch' | 'unknown'
export function getDaemonLaunchIdentity(
export async function getDaemonLaunchIdentity(
runtimeDir: string,
socketPath: string,
tokenPath: string,
expectedEntryPath: string,
protocolVersion = PROTOCOL_VERSION
): DaemonLaunchIdentity {
const parsedPid = readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
): Promise<DaemonLaunchIdentity> {
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
if (!parsedPid) {
return 'unknown'
}
@@ -486,19 +499,19 @@ export function getDaemonLaunchIdentity(
// carries daemon-entry.js, so use it to stop dev worktrees from reusing a
// daemon forked from a deleted sibling checkout. If command-line probing is
// unavailable, fail open so we don't kill live sessions unnecessarily.
const commandLine = getDaemonCommandLine(parsedPid.pid)
const commandLine = await getDaemonCommandLine(parsedPid.pid)
if (!commandLine) {
return 'unknown'
}
return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch'
}
function readVerifiedDaemonPid(
async function readVerifiedDaemonPid(
runtimeDir: string,
socketPath: string,
tokenPath: string,
protocolVersion = PROTOCOL_VERSION
): ParsedDaemonPid | null {
): Promise<ParsedDaemonPid | null> {
let parsedPid: ParsedDaemonPid | null
try {
parsedPid = parseDaemonPidFile(
@@ -508,21 +521,24 @@ function readVerifiedDaemonPid(
return null
}
if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) {
if (
!parsedPid ||
!(await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs))
) {
return null
}
return parsedPid
}
export function isDaemonStaleForCurrentBundle(
export async function isDaemonStaleForCurrentBundle(
runtimeDir: string,
socketPath: string,
tokenPath: string,
currentAppVersion: string,
protocolVersion = PROTOCOL_VERSION
): boolean {
const parsedPid = readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
): Promise<boolean> {
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
if (!parsedPid) {
return false
}
@@ -547,7 +563,10 @@ export async function killStaleDaemon(
let killedDaemon = false
try {
const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
if (parsedPid && isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) {
if (
parsedPid &&
(await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs))
) {
const { pid, startedAtMs } = parsedPid
process.kill(pid, 'SIGTERM')
const deadline = Date.now() + KILL_WAIT_MS
@@ -566,7 +585,7 @@ export async function killStaleDaemon(
// window is long enough for the pid to be recycled if the original
// daemon died during the wait. Without this, we'd SIGKILL an unrelated
// process that happens to now own the same pid.
if (!isDaemonProcess(pid, socketPath, tokenPath, startedAtMs)) {
if (!(await isDaemonProcess(pid, socketPath, tokenPath, startedAtMs))) {
console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled')
exited = true
killedDaemon = true
+136 -3
View File
@@ -20,6 +20,7 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -66,6 +67,7 @@ const {
}
})
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
const healthCheckDaemonMock = vi.fn(async () => true)
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
@@ -101,6 +103,7 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock }))
vi.mock('net', () => ({ connect: netConnectMock }))
vi.mock('./daemon-health', () => ({
checkDaemonHealth: checkDaemonHealthMock,
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
healthCheckDaemon: healthCheckDaemonMock,
@@ -268,7 +272,10 @@ async function importFresh() {
setLocalPtyProviderMock.mockClear()
unbindLocalProviderListenersMock.mockClear()
rebindLocalProviderListenersMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
healthCheckDaemonMock.mockResolvedValue(true)
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
getDaemonLaunchIdentityMock.mockClear()
@@ -1025,9 +1032,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
probeSocketExistsMock.mockImplementation(
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
)
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
getAppPathMock.mockReturnValue('/fake/app/out/main')
healthCheckDaemonMock.mockResolvedValue(false)
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
@@ -1068,8 +1075,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after readiness', async () => {
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
healthCheckDaemonMock.mockResolvedValue(false)
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
@@ -1123,8 +1130,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after startup error', async () => {
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
healthCheckDaemonMock.mockResolvedValue(false)
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
@@ -1167,6 +1174,132 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(child.unref).not.toHaveBeenCalled()
})
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const requestMock = vi.fn(async (method: string) => {
if (method === 'listSessions') {
return {
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
}
}
return {}
})
const disconnectMock = vi.fn()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
request: requestMock,
disconnect: disconnectMock
}
})
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
healthCheckDaemonMock.mockResolvedValueOnce(false)
await launcher('/fake/socket', '/fake/token')
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined)
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
})
it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {
throw new Error('daemon is wedged')
}),
request: vi.fn(),
disconnect: vi.fn()
}
})
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => ({
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready' }))
}
return this
},
off() {
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}))
await launcher('/fake/socket', '/fake/token')
expect(killStaleDaemonMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token'
)
expect(forkMock).toHaveBeenCalled()
})
it('replaces a health-check-failing daemon when no live sessions would be lost', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
return {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready' }))
}
return this
},
off(event: string, cb: (arg?: unknown) => void) {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}
})
await launcher('/fake/socket', '/fake/token')
expect(killStaleDaemonMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token'
)
expect(forkMock).toHaveBeenCalledWith(
'/fake/app/out/main/daemon-entry.js',
['--socket', '/fake/socket', '--token', '/fake/token'],
expect.objectContaining({ detached: true })
)
})
it('preserves a packaged healthy daemon when its app bundle is current', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
+59 -2
View File
@@ -40,6 +40,16 @@ import {
unbindLocalProviderListeners,
rebindLocalProviderListeners
} from '../ipc/pty'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
// Why: daemon init runs concurrently with window load, so harness-side stderr
// arrival times are useless — in-process `t` lets the startup benchmark derive
// how long the daemon cold-start path actually took.
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
let spawner: DaemonSpawner | null = null
let adapter: DaemonPtyRouter | DaemonPtyAdapter | null = null
@@ -184,10 +194,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
app.isPackaged &&
isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())
(await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
@@ -209,6 +219,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return createPreservedDaemonHandle(runtimeDir)
}
}
} else {
// Why: a busy machine (e.g. right after an update) can time out the
// health check while the daemon is alive and owning terminals. Killing
// it would destroy every live session, so re-verify with a session list
// first. Only a verified non-empty list preserves: a daemon that cannot
// even list sessions cannot serve terminals, and replacing it is the
// only recovery.
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== null && liveSessionCount > 0) {
console.warn(
`[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return createPreservedDaemonHandle(runtimeDir)
}
}
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
@@ -330,6 +354,15 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
logDaemonMilestone('daemon-init-start')
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init
// that deterministically outlasts the first-window timeout. Real triggers
// (stale-daemon cleanup, legacy probes on a busy disk) are not controllable
// from a test.
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs))
}
const runtimeDir = getRuntimeDir()
const newSpawner = new DaemonSpawner({
@@ -341,6 +374,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
// throws, a stale spawner would prevent shutdownDaemon() from cleaning up
// correctly on retry.
const info = await newSpawner.ensureRunning()
logDaemonMilestone('daemon-current-ready')
if (signal?.aborted) {
// Why: startup fail-open may already have allowed fallback LocalPtyProvider
// PTYs to spawn. A late daemon swap would strand those PTYs on the old owner.
@@ -386,6 +420,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
// before daemon init finishes. Rebind here so daemon PTYs still fan out
// data/exit events through the renderer and runtime listeners.
rebindLocalProviderListeners()
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
}
// Why: the Manage Sessions IPC handlers need read access to the current
@@ -614,6 +649,28 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
if (!(await probeSocket(socketPath))) {
// Why: dead legacy daemons leave pid/token files behind forever (one per
// protocol bump). A stale pid eventually gets recycled by an unrelated
// process, turning any future identity check into a PowerShell spawn.
// The socket is provably dead, so remove the leftovers — mirrors what
// cleanupDaemonForProtocol already does for the current version.
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
getDaemonTokenPath(runtimeDir, protocolVersion)
]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
}
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
continue
}
// Why: old daemon PTYs can be running long-lived agents during an app
+126 -46
View File
@@ -7,7 +7,6 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getHistorySessionDirName } from './history-paths'
import type { SubprocessHandle } from './session'
import type { GetSnapshotResult, TerminalSnapshot } from './types'
import type * as DaemonHealthModule from './daemon-health'
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
@@ -33,7 +32,9 @@ function createMockSubprocess(): SubprocessHandle & {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
pid: 66666,
// Why: getCwd falls back to OS pid lookup; a plausible fake pid can
// collide with an unrelated local process and leak its cwd into tests.
pid: 999_999_999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
@@ -56,27 +57,6 @@ function createMockSubprocess(): SubprocessHandle & {
}
}
function createTestSnapshot(label: string): TerminalSnapshot {
return {
snapshotAnsi: label,
scrollbackAnsi: '',
rehydrateSequences: '',
cwd: null,
modes: {
bracketedPaste: false,
mouseTracking: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false,
applicationCursor: false,
alternateScreen: false
},
cols: 80,
rows: 24,
scrollbackLines: 0
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
@@ -170,6 +150,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await adapter.shutdown(id, { immediate: false })
expect(lastSubprocess.kill).toHaveBeenCalled()
})
it('force-kills immediately when requested', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
await adapter.shutdown(id, { immediate: true })
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).toHaveBeenCalled()
})
})
describe('sendSignal', () => {
@@ -534,7 +521,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
)
})
it('checkpoints only dirty sessions on the periodic timer', async () => {
it('appends increments for only dirty sessions on the periodic timer', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
adapterClass.CHECKPOINT_INTERVAL_MS = 25
@@ -548,22 +535,33 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
sessionId: 'dirty-checkpoint'
})
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements')
await new Promise((r) => setTimeout(r, 80))
// Why: idle terminals can be numerous. A periodic pass with no data
// must not serialize every live daemon session just because it exists.
expect(checkpointSpy).not.toHaveBeenCalled()
expect(appendSpy).not.toHaveBeenCalled()
lastSubprocess._simulateData('new output\r\n')
await waitFor(() => checkpointSpy.mock.calls.length === 1)
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('new output') })
)
await waitFor(() => appendSpy.mock.calls.length === 1)
expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [
{ kind: 'output', data: 'new output\r\n' }
])
// Why: the periodic tick must persist increments, never re-serialize
// the full emulator buffer (the issue #5096 stall).
expect(checkpointSpy).not.toHaveBeenCalled()
const logPath = join(historyDir, getHistorySessionDirName(id), 'output.log')
await waitFor(() => {
try {
return readFileSync(logPath).includes('new output')
} catch {
return false
}
})
await new Promise((r) => setTimeout(r, 80))
expect(checkpointSpy).toHaveBeenCalledTimes(1)
expect(appendSpy).toHaveBeenCalledTimes(1)
} finally {
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
}
@@ -575,30 +573,38 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const requestedSessionIds: string[] = []
let inFlight = 0
let maxInFlight = 0
const request = vi.fn(
async (_type: string, payload: { sessionId: string }): Promise<GetSnapshotResult> => {
requestedSessionIds.push(payload.sessionId)
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>((resolve) => {
releaseSnapshotRequests.push(() => {
inFlight--
resolve()
})
const request = vi.fn(async (_type: string, payload: { sessionId: string }) => {
requestedSessionIds.push(payload.sessionId)
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>((resolve) => {
releaseSnapshotRequests.push(() => {
inFlight--
resolve()
})
return { snapshot: createTestSnapshot(payload.sessionId) }
})
return {
records: [{ kind: 'output', data: payload.sessionId }],
seq: 1,
overflowed: false,
snapshot: null
}
)
})
const checkpoint = vi.fn(async () => {})
const appendIncrements = vi.fn(async () => 'ok' as const)
const dispose = vi.fn(async () => {})
const disconnect = vi.fn()
const internals = historyAdapter as unknown as {
client: { request: typeof request; disconnect: typeof disconnect }
historyManager: { checkpoint: typeof checkpoint; dispose: typeof dispose }
historyManager: {
checkpoint: typeof checkpoint
appendIncrements: typeof appendIncrements
dispose: typeof dispose
}
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
}
internals.client = { request, disconnect }
internals.historyManager = { checkpoint, dispose }
internals.historyManager = { checkpoint, appendIncrements, dispose }
const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f'])
await waitFor(() => requestedSessionIds.length === 4)
@@ -617,7 +623,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
release()
}
await expect(checkpointing).resolves.toEqual(new Set(['a', 'b', 'c', 'd', 'e', 'f']))
expect(checkpoint).toHaveBeenCalledTimes(6)
expect(appendIncrements).toHaveBeenCalledTimes(6)
expect(checkpoint).not.toHaveBeenCalled()
})
it('does not schedule a checkpoint timer until a session is dirty', async () => {
@@ -807,6 +814,27 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false)
})
it('writes a final checkpoint before keepHistory shutdown', async () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const { id } = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: 'sleep-checkpoint'
})
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
lastSubprocess._simulateData('fresh output before sleep\r\n')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') })
)
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
})
it('returns cold restore data when disk history has unclean shutdown', async () => {
// Simulate a previous daemon crash: write history files without endedAt
const sessionId = 'cold-restore-test'
@@ -840,6 +868,58 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
adapterClass.CHECKPOINT_INTERVAL_MS = 25
try {
// Simulate a previous daemon crash with stale checkpoint + log files.
const sessionId = 'cold-restore-reanchor'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/myapp',
cols: 80,
rows: 24,
startedAt: '2026-04-15T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(join(sessionDir, 'scrollback.bin'), 'pre-crash output\r\n')
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore).toBeDefined()
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements')
lastSubprocess._simulateData('revived session output\r\n')
await waitFor(() => checkpointSpy.mock.calls.length === 1)
// Why: appending the fresh session's records to the pre-crash log
// would be rejected by the sequence check on a second crash, reverting
// the restore to pre-crash content. The full checkpoint resets the log
// to a new generation.
expect(appendSpy).not.toHaveBeenCalled()
expect(checkpointSpy).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('revived session') })
)
// Subsequent ticks return to incremental appends.
lastSubprocess._simulateData('later output\r\n')
await waitFor(() => appendSpy.mock.calls.length === 1)
expect(checkpointSpy).toHaveBeenCalledTimes(1)
} finally {
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
}
})
it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => {
const sessionId = 'sticky-cache-test'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
+98 -21
View File
@@ -16,7 +16,8 @@ import {
type DaemonEvent,
type GetSnapshotResult,
type ListSessionsResult,
type SessionInfo
type SessionInfo,
type TakePendingOutputResult
} from './types'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { isShellProcess } from '../../shared/agent-detection'
@@ -73,11 +74,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
// log belong to the pre-crash session. Incremental appends would land on
// that stale log (and be rejected by its sequence check on restore), so the
// first tick must re-anchor with a full snapshot checkpoint, which resets
// the log to a new generation.
private sessionsNeedingFullCheckpoint = new Set<string>()
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
private checkpointInFlight: Promise<void> | null = null
// Why: checkpoint-based persistence requires the getSnapshot RPC (v4+).
// Legacy daemons reject it, causing noisy log spam every 5 seconds.
private supportsCheckpoints: boolean
// Why: incremental checkpoints require the takePendingOutput RPC (v13+).
// Against older daemons the tick falls back to full-snapshot checkpoints.
private supportsIncrementalCheckpoints: boolean
private static CHECKPOINT_INTERVAL_MS = 5_000
constructor(opts: DaemonPtyAdapterOptions) {
@@ -93,6 +103,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.supportsCheckpoints = this.protocolVersion >= 4
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
}
getHistoryManager(): HistoryManager | null {
@@ -185,6 +196,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// the next 5s tick, the checkpoint is the only recovery data available.
if (this.historyManager) {
this.historyManager.registerWriter(sessionId)
this.sessionsNeedingFullCheckpoint.add(sessionId)
}
if (coldRestore) {
this.coldRestoreCache.set(sessionId, coldRestore)
@@ -252,13 +264,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
if (opts.keepHistory && this.historyManager && this.supportsCheckpoints) {
// Why: sleep kills the live PTY before the periodic checkpoint may run.
// Capture the daemon buffer now so wake can restore the pane users left.
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
// Force a final snapshot so wake can restore the pane users left.
if (opts.keepHistory) {
if (this.checkpointInFlight) {
await this.checkpointInFlight
}
await this.checkpointSessions([id])
await this.checkpointSessions([id], { final: true })
const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
if (coldRestore) {
@@ -266,7 +278,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sleepRestoreSessionIds.add(id)
}
}
await this.client.request('kill', { sessionId: id })
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
if (!opts.keepHistory) {
@@ -387,7 +399,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
/** Called on app launch. Lists daemon sessions, kills orphans whose
* workspaceId no longer exists, and caches alive session IDs. */
* workspaceId no longer exists, and caches alive session IDs.
*
* IMPORTANT: a session id embeds the worktree id it was minted under, which is
* the worktree's *path* at spawn time. When a worktree folder is renamed, its
* id changes but live sessions keep the old id. Callers MUST therefore seed
* `validWorktreeIds` with each live worktree's `WorktreeMeta.priorWorktreeIds`
* (the pre-rename aliases) or those sessions will be reaped as false orphans.
* This reconcile has no production caller yet; wire the alias in when it gains
* one. */
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
@@ -647,17 +667,22 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
// Why: the adapter runs in the Electron main process and does not have direct
// access to daemon Session objects. It calls the getSnapshot RPC over the
// daemon socket per session. Returns a promise that resolves when all
// checkpoint writes complete (callers that don't need to wait can void it).
// access to daemon Session objects. It calls checkpoint RPCs over the daemon
// socket per session. Returns a promise that resolves when all checkpoint
// writes complete (callers that don't need to wait can void it).
// Why final=true here: this runs on clean disconnect, where the full-depth
// snapshot (not the increment log) must be the restore source.
private async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds)
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
for (const sessionId of completed) {
this.dirtySessionVersions.delete(sessionId)
}
}
private async checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>> {
private async checkpointSessions(
sessionIds: Iterable<string>,
opts?: { final?: boolean }
): Promise<Set<string>> {
const completed = new Set<string>()
if (!this.historyManager) {
return completed
@@ -673,16 +698,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
const sessionId = ids[index]
await this.client
.request<GetSnapshotResult>('getSnapshot', { sessionId })
.then((result) => {
if (result.snapshot && this.historyManager) {
return this.historyManager.checkpoint(sessionId, result.snapshot).then(() => {
completed.add(sessionId)
})
}
await this.checkpointSession(sessionId, opts?.final === true)
.then(() => {
completed.add(sessionId)
return undefined
})
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
}
@@ -697,6 +715,65 @@ export class DaemonPtyAdapter implements IPtyProvider {
return completed
}
private async checkpointSession(sessionId: string, final: boolean): Promise<void> {
if (!this.supportsIncrementalCheckpoints) {
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
if (result.snapshot && this.historyManager) {
await this.historyManager.checkpoint(sessionId, result.snapshot)
}
return
}
if (final || this.sessionsNeedingFullCheckpoint.has(sessionId)) {
// Why take-with-snapshot instead of plain getSnapshot: the take clears
// the daemon's pending records in the same synchronous turn as the
// serialize. A plain snapshot would leave pre-snapshot records pending;
// a later warm reattach would append them to the fresh log and cold
// restore would replay them on top of a checkpoint that already
// contains them.
await this.takeSnapshotAndCheckpoint(sessionId)
this.sessionsNeedingFullCheckpoint.delete(sessionId)
return
}
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId
})
if (!take) {
return
}
if (take.overflowed) {
// Why: overflow dropped records, so the log has a hole — only a full
// snapshot (which reflects everything ever written) can re-anchor it.
await this.takeSnapshotAndCheckpoint(sessionId)
return
}
if (take.records.length === 0) {
return
}
if (!this.historyManager) {
return
}
const appendResult = await this.historyManager.appendIncrements(
sessionId,
take.seq,
take.records
)
if (appendResult === 'needs-checkpoint') {
// Why dropping take.records is lossless: they were applied to the live
// emulator before the take, so the snapshot below contains them.
await this.takeSnapshotAndCheckpoint(sessionId)
}
}
private async takeSnapshotAndCheckpoint(sessionId: string): Promise<void> {
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId,
includeSnapshot: true
})
if (take?.snapshot && this.historyManager) {
await this.historyManager.checkpoint(sessionId, take.snapshot)
}
}
// Why: when the daemon process dies, operations fail with ENOENT (socket
// gone), ECONNREFUSED, or "Connection lost" (socket closed mid-request).
// Rather than leaving all terminals permanently broken until app restart,
+2 -2
View File
@@ -64,8 +64,8 @@ export class DaemonPtyProvider {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id })
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
}
onData(callback: (payload: { id: string; data: string }) => void): () => void {
@@ -145,6 +145,15 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy unavailable'))
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
})
it('merges startup reconciliation and updates route mappings', async () => {
const current = createAdapter('current', [], {
alive: ['current-alive'],
+4 -4
View File
@@ -121,10 +121,10 @@ export class DaemonPtyRouter implements IPtyProvider {
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
const results = await Promise.allSettled(
this.allAdapters().map((adapter) => adapter.listProcesses())
)
return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
// cannot provide a trustworthy process list.
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
return results.flat()
}
async getDefaultShell(): Promise<string> {
+14
View File
@@ -198,6 +198,20 @@ describe('DaemonServer', () => {
expect(result).toEqual({ pong: true })
})
it('replies with an error to unknown request types and keeps serving', async () => {
await startServer()
const c = await connectClient()
// Why: downgraded clients can send request types this daemon does not
// know. Reject gracefully instead of crashing the session server.
await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow(
'Unknown request type: definitelyUnknownRequest'
)
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
pong: true
})
})
it('handles systemResolverHealth', async () => {
await startServer()
const c = await connectClient()
+19 -1
View File
@@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import {
PROTOCOL_VERSION,
NOTIFY_PREFIX,
@@ -22,6 +23,7 @@ import {
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
ptySpawnHealthCheck?: () => Promise<void>
spawnSubprocess: (opts: {
sessionId: string
cols: number
@@ -45,6 +47,7 @@ export class DaemonServer {
private host: TerminalHost
private socketPath: string
private tokenPath: string
private ptySpawnHealthCheck: () => Promise<void>
private clients = new Map<string, ConnectedClient>()
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
@@ -62,6 +65,7 @@ export class DaemonServer {
this.tokenPath = opts.tokenPath
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
}
async start(): Promise<void> {
@@ -341,7 +345,7 @@ export class DaemonServer {
case 'kill':
this.lastInputAtBySessionId.delete(request.payload.sessionId)
this.host.kill(request.payload.sessionId)
this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
return {}
case 'signal':
@@ -369,12 +373,26 @@ export class DaemonServer {
case 'getSnapshot':
return { snapshot: this.host.getSnapshot(request.payload.sessionId) }
case 'takePendingOutput':
// Why no await before this call: with includeSnapshot, drain and
// serialize must share one synchronous turn — an intervening await
// would let PTY data land in between, and cold restore would replay
// those bytes on top of a snapshot that already contains them.
return this.host.takePendingOutput(
request.payload.sessionId,
request.payload.includeSnapshot === true
)
case 'ping':
return { pong: true }
case 'systemResolverHealth':
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
if (request.payload.killSessions) {
this.host.dispose()
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest'
import { performance } from 'node:perf_hooks'
import { writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { HeadlessEmulator } from './headless-emulator'
// Benchmark harness for issue #5096 (terminal output delay / UI lag growing
// with session history). Run with:
// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \
// src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts \
// --config config/vitest.config.ts
//
// Why these measurements: during an active agent session every PTY chunk marks
// the session dirty, so daemon-pty-adapter checkpoints every 5s. getSnapshot()
// serializes the full headless buffer synchronously on the daemon event loop
// (stalling the PTY pump), and history-manager JSON.stringifies the result on
// the Electron main process (stalling input IPC). Both stalls scale with
// buffer content, which matches the report that clearing history fixes the lag.
const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1'
const COLS = 200
const ROWS = 50
const DAEMON_DEFAULT_SCROLLBACK = 5_000
const RENDERER_SCALE_SCROLLBACK = 50_000
const SNAPSHOT_ITERATIONS = 5
const FILL_WRITE_CHUNK_LINES = 200
type BenchRow = {
scenario: string
bufferRows: number
fillMs: number
snapshotMedianMs: number
snapshotMaxMs: number
snapshotBytes: number
checkpointStringifyMs: number
reflowMs: number
}
function agentTranscriptLine(index: number): string {
// Mimic agent TUI transcripts: SGR colors, tool-call box drawing, varied
// widths — serialized cost depends on attribute churn, not just row count.
const color = 30 + (index % 8)
const variant = index % 4
if (variant === 0) {
return `\x1b[1;${color}m● Tool call ${index}\x1b[0m \x1b[2m(src/example/file-${index % 97}.ts)\x1b[0m\r\n`
}
if (variant === 1) {
return `\x1b[${color}m│\x1b[0m ${'response token '.repeat(1 + (index % 7))}#${index}\r\n`
}
if (variant === 2) {
return `\x1b[38;5;${index % 256}m${'═'.repeat(20 + (index % 60))}\x1b[0m\r\n`
}
return ` \x1b[32m+\x1b[0m line ${index}: ${'x'.repeat(10 + (index % 80))}\r\n`
}
function fillEmulator(emulator: HeadlessEmulator, lines: number): number {
const start = performance.now()
for (let offset = 0; offset < lines; offset += FILL_WRITE_CHUNK_LINES) {
let chunk = ''
const end = Math.min(offset + FILL_WRITE_CHUNK_LINES, lines)
for (let index = offset; index < end; index += 1) {
chunk += agentTranscriptLine(index)
}
void emulator.write(chunk)
}
return performance.now() - start
}
function medianOf(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)] ?? 0
}
function measureCheckpointStringify(emulator: HeadlessEmulator): number {
const snapshot = emulator.getSnapshot()
const start = performance.now()
// Mirrors history-manager.ts checkpoint(): the payload main stringifies and
// writes to disk every 5s per dirty session.
JSON.stringify({
snapshotAnsi: snapshot.snapshotAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
rehydrateSequences: snapshot.rehydrateSequences,
cwd: snapshot.cwd,
cols: snapshot.cols,
rows: snapshot.rows,
modes: snapshot.modes,
scrollbackLines: snapshot.scrollbackLines,
checkpointedAt: new Date().toISOString()
})
return performance.now() - start
}
function measureReflow(emulator: HeadlessEmulator): number {
const start = performance.now()
emulator.resize(COLS - 1, ROWS)
emulator.resize(COLS, ROWS)
return performance.now() - start
}
function runScenario(scenario: string, scrollback: number, fillLines: number): BenchRow {
const emulator = new HeadlessEmulator({ cols: COLS, rows: ROWS, scrollback })
try {
const fillMs = fillEmulator(emulator, fillLines)
const durations: number[] = []
let snapshotBytes = 0
for (let iteration = 0; iteration < SNAPSHOT_ITERATIONS; iteration += 1) {
const start = performance.now()
const snapshot = emulator.getSnapshot()
durations.push(performance.now() - start)
snapshotBytes = Buffer.byteLength(snapshot.snapshotAnsi, 'utf8')
}
const checkpointStringifyMs = measureCheckpointStringify(emulator)
const reflowMs = measureReflow(emulator)
return {
scenario,
bufferRows: fillLines,
fillMs: round(fillMs),
snapshotMedianMs: round(medianOf(durations)),
snapshotMaxMs: round(Math.max(...durations)),
snapshotBytes,
checkpointStringifyMs: round(checkpointStringifyMs),
reflowMs: round(reflowMs)
}
} finally {
emulator.dispose()
}
}
function round(value: number): number {
return Math.round(value * 100) / 100
}
// Why a file: vitest's default reporter swallows console output from passing
// tests; the measurements are the deliverable of this harness.
function writeBenchReport(fileName: string, report: unknown): void {
const reportPath = join(tmpdir(), fileName)
writeFileSync(reportPath, JSON.stringify(report, null, 2))
process.stdout.write(`\n[bench] report written to ${reportPath}\n`)
}
// Why: models the daemon event loop. PTY chunks and the checkpoint work share
// one thread in the daemon process; the worst inter-chunk gap during a
// checkpoint is the output latency a user sees when a tick lands.
// 'snapshot' models the pre-#5096 design (full serialize per tick);
// 'incremental-take' models the replacement (drain pending records — the
// daemon-side cost of the takePendingOutput RPC).
async function measureStreamInterference(
checkpointAtChunk: number | null,
mode: 'snapshot' | 'incremental-take' = 'snapshot'
): Promise<{
maxGapMs: number
checkpointMs: number
}> {
const emulator = new HeadlessEmulator({
cols: COLS,
rows: ROWS,
scrollback: DAEMON_DEFAULT_SCROLLBACK
})
try {
fillEmulator(emulator, DAEMON_DEFAULT_SCROLLBACK)
const totalChunks = 200
let pending: { kind: 'output'; data: string }[] = []
let maxGapMs = 0
let checkpointMs = 0
let lastChunkAt = performance.now()
for (let chunk = 0; chunk < totalChunks; chunk += 1) {
await new Promise<void>((resolve) => setTimeout(resolve, 2))
const now = performance.now()
maxGapMs = Math.max(maxGapMs, now - lastChunkAt)
const line = agentTranscriptLine(chunk)
void emulator.write(line)
pending.push({ kind: 'output', data: line })
lastChunkAt = performance.now()
if (chunk === checkpointAtChunk) {
const start = performance.now()
if (mode === 'snapshot') {
emulator.getSnapshot()
} else {
const taken = pending
pending = []
JSON.stringify({ records: taken, seq: 1, overflowed: false, snapshot: null })
}
checkpointMs = performance.now() - start
}
}
return { maxGapMs: round(maxGapMs), checkpointMs: round(checkpointMs) }
} finally {
emulator.dispose()
}
}
describe.skipIf(!benchEnabled)('headless emulator snapshot cost (issue #5096 harness)', () => {
it('measures daemon checkpoint cost across history sizes', () => {
const results: BenchRow[] = [
runScenario('empty buffer', DAEMON_DEFAULT_SCROLLBACK, 0),
runScenario('short session (1k rows)', DAEMON_DEFAULT_SCROLLBACK, 1_000),
runScenario('daemon cap (5k rows)', DAEMON_DEFAULT_SCROLLBACK, DAEMON_DEFAULT_SCROLLBACK),
runScenario('renderer-scale (50k rows)', RENDERER_SCALE_SCROLLBACK, RENDERER_SCALE_SCROLLBACK)
]
writeBenchReport('orca-headless-snapshot-bench.json', {
interpretation:
'snapshotMedianMs stalls the daemon PTY pump per 5s checkpoint; ' +
'checkpointStringifyMs stalls Electron main (input IPC); ' +
'reflowMs models the renderer-side resize/reflow stall at the same fill.',
cols: COLS,
rows: ROWS,
results
})
expect(results).toHaveLength(4)
for (const row of results) {
expect(row.snapshotMedianMs).toBeGreaterThanOrEqual(0)
}
}, 300_000)
it('measures PTY pump stall when a checkpoint lands mid-stream', async () => {
const baseline = await measureStreamInterference(null)
const withFullSnapshot = await measureStreamInterference(100, 'snapshot')
const withIncrementalTake = await measureStreamInterference(100, 'incremental-take')
writeBenchReport('orca-checkpoint-interference-bench.json', {
interpretation:
'maxGapMs is the worst chunk-to-chunk forwarding delay on the simulated daemon loop. ' +
'withFullSnapshot models the old per-5s full serialize; withIncrementalTake models ' +
'the incremental checkpoint take that replaced it.',
baseline,
withFullSnapshot,
withIncrementalTake
})
expect(withFullSnapshot.checkpointMs).toBeGreaterThan(0)
}, 300_000)
})
+37 -41
View File
@@ -2,6 +2,7 @@ import './xterm-env-polyfill'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { extractLastOscTitle } from '../../shared/agent-detection'
import { parseFileUriPath } from './osc7-file-uri'
import type { TerminalSnapshot, TerminalModes } from './types'
export type HeadlessEmulatorOptions = {
@@ -27,34 +28,6 @@ const OSC_SCAN_TAIL_LIMIT = 4096
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
function parseFileUriPath(uri: string): string | null {
try {
const url = new URL(uri)
if (url.protocol !== 'file:') {
return null
}
const decodedPath = decodeURIComponent(url.pathname)
if (process.platform !== 'win32') {
return decodedPath
}
// Why: Windows OSC-7 cwd updates can describe both drive-letter paths
// (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the
// hostname when present so live cwd tracking, snapshots, and restore all
// round-trip to a native Windows path instead of dropping the server name.
if (url.hostname) {
return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
}
if (/^\/[A-Za-z]:/.test(decodedPath)) {
return decodedPath.slice(1)
}
return decodedPath.replace(/\//g, '\\')
} catch {
return null
}
}
export class HeadlessEmulator {
private terminal: Terminal
private serializer: SerializeAddon
@@ -97,21 +70,10 @@ export class HeadlessEmulator {
return Promise.resolve()
}
const oscInput = this.oscScanTail + data
this.oscScanTail = this.extractOscScanTail(oscInput)
this.scanOsc7(oscInput)
const lastTitle = extractLastOscTitle(oscInput)
if (lastTitle !== null) {
this.lastTitle = lastTitle
}
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
if (typeof writeSync === 'function') {
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
this.scanPrivateModes(data)
if (this.tryWriteSync(data)) {
return Promise.resolve()
}
this.scanInputForOscState(data)
return new Promise<void>((resolve) => {
this.terminal.write(data, () => {
// Why: snapshots combine serialized xterm state with mirrored mouse
@@ -122,6 +84,40 @@ export class HeadlessEmulator {
})
}
/** Synchronous write used by cold-restore log replay, where a snapshot is
* taken immediately after the last record and queued async writes would
* serialize a half-applied stream. Returns false when xterm's synchronous
* write path is unavailable — callers must then abandon the replay. */
writeSync(data: string): boolean {
if (this.disposed) {
return false
}
return this.tryWriteSync(data)
}
private tryWriteSync(data: string): boolean {
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
if (typeof writeSync !== 'function') {
return false
}
this.scanInputForOscState(data)
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
this.scanPrivateModes(data)
return true
}
private scanInputForOscState(data: string): void {
const oscInput = this.oscScanTail + data
this.oscScanTail = this.extractOscScanTail(oscInput)
this.scanOsc7(oscInput)
const lastTitle = extractLastOscTitle(oscInput)
if (lastTitle !== null) {
this.lastTitle = lastTitle
}
}
resize(cols: number, rows: number): void {
if (this.disposed) {
return
+142 -13
View File
@@ -5,11 +5,27 @@ import {
readFileSync,
existsSync,
rmSync,
renameSync,
unlinkSync
unlinkSync,
openSync,
closeSync,
readSync,
fstatSync,
promises as fsPromises
} from 'fs'
import { getHistorySessionDirName } from './history-paths'
import type { TerminalSnapshot } from './types'
import {
decodeLogHeader,
encodeLogBatch,
encodeLogHeader,
LOG_HEADER_BYTES
} from './terminal-history-log'
import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types'
// Why 5MB: bounds both cold-restore replay time and disk usage per session.
// Reaching the cap triggers one full snapshot checkpoint (which subsumes and
// resets the log) — one O(buffer) serialize per ~5MB of output instead of one
// per 5-second tick.
const LOG_MAX_BYTES = 5 * 1024 * 1024
export type SessionMeta = {
cwd: string
@@ -29,6 +45,12 @@ export type OpenSessionOptions = {
type SessionWriter = {
dir: string
checkpointPath: string
logPath: string
/** Generation of the on-disk log header. Null until lazily resolved on the
* first append after a warm registerWriter (the file may predate us). */
logGeneration: number | null
/** Current log file size. Null until lazily resolved alongside generation. */
logBytes: number | null
}
export type HistoryManagerOptions = {
@@ -70,7 +92,8 @@ export class HistoryManager {
// because the reader falls back to scrollback.bin when no checkpoint
// exists.
const checkpointPath = join(dir, 'checkpoint.json')
for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin')]) {
const logPath = join(dir, 'output.log')
for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin'), logPath]) {
try {
unlinkSync(staleFile)
} catch {
@@ -80,7 +103,10 @@ export class HistoryManager {
this.writers.set(sessionId, {
dir,
checkpointPath
checkpointPath,
logPath,
logGeneration: 0,
logBytes: 0
})
} catch (err) {
this.handleWriteError(sessionId, err)
@@ -99,7 +125,10 @@ export class HistoryManager {
const dir = join(this.basePath, getHistorySessionDirName(sessionId))
this.writers.set(sessionId, {
dir,
checkpointPath: join(dir, 'checkpoint.json')
checkpointPath: join(dir, 'checkpoint.json'),
logPath: join(dir, 'output.log'),
logGeneration: null,
logBytes: null
})
}
@@ -121,9 +150,50 @@ export class HistoryManager {
}
}
/** Appends one take batch to the incremental log. Returns 'needs-checkpoint'
* when the log is at capacity — the caller must take a full snapshot, which
* subsumes the un-appended records (they were already applied to the live
* emulator) and resets the log via checkpoint(). */
async appendIncrements(
sessionId: string,
seq: number,
records: PendingOutputRecord[]
): Promise<'ok' | 'needs-checkpoint'> {
if (this.disabledSessions.has(sessionId) || records.length === 0) {
return 'ok'
}
const writer = this.writers.get(sessionId)
if (!writer) {
return 'ok'
}
try {
this.resolveLogState(writer)
const batch = encodeLogBatch(seq, records)
// Why max(..., header): a fresh log gets its header written below, so
// the projected size must include it or the cap can be overshot.
const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length
if (projectedBytes > LOG_MAX_BYTES) {
return 'needs-checkpoint'
}
if (writer.logBytes === 0) {
// Why: header carries the generation that ties this log to its base
// checkpoint; written lazily so warm reattaches never clobber a log
// that already has appended batches.
await fsPromises.writeFile(writer.logPath, encodeLogHeader(writer.logGeneration ?? 0))
writer.logBytes = LOG_HEADER_BYTES
}
await fsPromises.appendFile(writer.logPath, batch)
writer.logBytes = (writer.logBytes ?? LOG_HEADER_BYTES) + batch.length
return 'ok'
} catch (err) {
this.handleWriteError(sessionId, err)
return 'ok'
}
}
// Why: replaces the old appendData (which wrote every PTY chunk to disk).
// Checkpoints happen every ~5 seconds from a timer, not on every data event,
// so disk I/O drops from O(PTY throughput) to O(1 write per interval).
// Full checkpoints are now rare (clean disconnect, pending-buffer overflow,
// log cap); the 5s tick appends increments via appendIncrements instead.
async checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<void> {
if (this.disabledSessions.has(sessionId)) {
return
@@ -144,7 +214,9 @@ export class HistoryManager {
effectiveCwd = meta?.cwd ?? null
}
const data = JSON.stringify({
this.resolveLogState(writer)
const generation = (writer.logGeneration ?? 0) + 1
const checkpointFile: TerminalCheckpointFile = {
snapshotAnsi: snapshot.snapshotAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
rehydrateSequences: snapshot.rehydrateSequences,
@@ -153,19 +225,76 @@ export class HistoryManager {
rows: snapshot.rows,
modes: snapshot.modes,
scrollbackLines: snapshot.scrollbackLines,
generation,
checkpointedAt: new Date().toISOString()
})
}
const data = JSON.stringify(checkpointFile)
// Why: atomic write via tmp+rename prevents half-written checkpoints
// on crash. Reading a corrupt checkpoint is worse than reading a
// slightly stale one.
// slightly stale one. Async IO — a sync ~MB write (worse under
// antivirus scanning on Windows) would stall input/IPC for its
// duration. Overlap is prevented by the adapter's checkpointInFlight
// guard, which awaits this promise before the next tick.
const tmpPath = `${writer.checkpointPath}.tmp`
writeFileSync(tmpPath, data)
renameSync(tmpPath, writer.checkpointPath)
await fsPromises.writeFile(tmpPath, data)
await fsPromises.rename(tmpPath, writer.checkpointPath)
// Why: the snapshot subsumes every logged record, so the log resets to
// the new generation. Crash between rename and this reset is safe: the
// stale log's generation no longer matches the checkpoint's, so the
// restore reader ignores it.
await fsPromises.writeFile(writer.logPath, encodeLogHeader(generation))
writer.logGeneration = generation
writer.logBytes = LOG_HEADER_BYTES
} catch (err) {
this.handleWriteError(sessionId, err)
}
}
// Why: a warm registerWriter may attach to a session dir that already has a
// log (app relaunch while the daemon kept running). Generation and size are
// read from disk once so appends continue the existing stream instead of
// clobbering it.
private resolveLogState(writer: SessionWriter): void {
if (writer.logBytes !== null && writer.logGeneration !== null) {
return
}
let headerGeneration: number | null = null
let size = 0
try {
const fd = openSync(writer.logPath, 'r')
try {
size = fstatSync(fd).size
const header = Buffer.alloc(LOG_HEADER_BYTES)
if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) {
headerGeneration = decodeLogHeader(header)
}
} finally {
closeSync(fd)
}
} catch {
// Missing log file — fresh state below.
}
if (headerGeneration !== null) {
writer.logGeneration = headerGeneration
writer.logBytes = size
return
}
// Missing or unreadable header: logBytes = 0 makes the next append rewrite
// the file from scratch (writeFile truncates), so a garbage file cannot be
// extended.
writer.logBytes = 0
writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0
}
private readCheckpointGeneration(writer: SessionWriter): number | null {
try {
const checkpoint = JSON.parse(readFileSync(writer.checkpointPath, 'utf-8'))
return typeof checkpoint.generation === 'number' ? checkpoint.generation : null
} catch {
return null
}
}
async closeSession(sessionId: string, exitCode: number): Promise<void> {
const writer = this.writers.get(sessionId)
if (!writer) {
+127 -36
View File
@@ -1,8 +1,10 @@
import { join } from 'path'
import { readFileSync, existsSync, readdirSync } from 'fs'
import type { SessionMeta } from './history-manager'
import type { TerminalModes } from './types'
import type { TerminalCheckpointFile, TerminalModes } from './types'
import { getHistorySessionDirName } from './history-paths'
import { decodeTerminalHistoryLog } from './terminal-history-log'
import { HeadlessEmulator } from './headless-emulator'
export type ColdRestoreInfo = {
snapshotAnsi: string
@@ -33,44 +35,33 @@ export class HistoryReader {
return null
}
const checkpointPath = join(
this.basePath,
getHistorySessionDirName(sessionId),
'checkpoint.json'
)
if (!existsSync(checkpointPath)) {
// Why: backward compatibility with pre-checkpoint sessions. If the user
// upgrades and then the daemon crashes before a checkpoint is written,
// the old scrollback.bin is still the best recovery data available.
const sessionDir = join(this.basePath, getHistorySessionDirName(sessionId))
const checkpointPath = join(sessionDir, 'checkpoint.json')
const checkpointExists = existsSync(checkpointPath)
let checkpoint: TerminalCheckpointFile | null = null
if (checkpointExists) {
try {
checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf-8'))
} catch {
checkpoint = null
}
}
// Why log replay is preferred over the checkpoint alone: the log carries
// byte-exact output up to ~5s before the crash, while the checkpoint can
// be a full log-cap (~5MB of output) stale.
const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint)
if (logRestore) {
return logRestore
}
if (!checkpoint) {
// Why: backward compatibility with pre-checkpoint sessions, and corrupt
// checkpoints — the old scrollback.bin is the best remaining data.
return this.detectColdRestoreFromScrollback(sessionId, meta)
}
try {
const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf-8'))
// Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi
// (it's always ''). For non-alt-screen checkpoints, snapshotAnsi IS
// the normal buffer content and is safe to use as scrollback. For
// alt-screen checkpoints, snapshotAnsi is the serialized TUI buffer
// (not raw PTY stream), so truncateAltScreen won't find transition
// sequences and would return stale TUI content. Return empty instead
// — the adapter skips cold restore when scrollbackAnsi is falsy.
const scrollbackAnsi =
checkpoint.scrollbackAnsi ||
(checkpoint.modes?.alternateScreen ? '' : (checkpoint.snapshotAnsi ?? ''))
return {
snapshotAnsi: checkpoint.snapshotAnsi,
scrollbackAnsi,
rehydrateSequences: checkpoint.rehydrateSequences,
cwd: checkpoint.cwd,
cols: checkpoint.cols,
rows: checkpoint.rows,
modes: checkpoint.modes
}
} catch {
// Why: corrupt checkpoint — fall back to scrollback.bin rather than
// discarding recoverable data entirely.
return this.detectColdRestoreFromScrollback(sessionId, meta)
}
return this.coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta)
}
listRestorable(): string[] {
@@ -105,6 +96,106 @@ export class HistoryReader {
return restorable
}
// Why a scratch emulator: replaying base + raw records through the same
// emulator the daemon used reproduces the exact terminal state at the last
// appended batch — including alt-screen and mode handling — and reuses
// getSnapshot()'s normalization instead of string-level reconstruction.
private restoreFromIncrementalLog(
sessionDir: string,
meta: SessionMeta,
checkpoint: TerminalCheckpointFile | null
): ColdRestoreInfo | null {
let logBuffer: Buffer
try {
logBuffer = readFileSync(join(sessionDir, 'output.log'))
} catch {
return null
}
const log = decodeTerminalHistoryLog(logBuffer)
if (!log || log.batches.length === 0) {
return null
}
// Generation mismatch means the log does not continue this checkpoint
// (e.g. crash between checkpoint rename and log reset, or a pre-log
// checkpoint without a generation field). Replaying it would duplicate or
// garble content; the checkpoint alone is consistent.
if (checkpoint) {
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
return null
}
} else if (log.generation !== 0) {
return null
}
const emulator = new HeadlessEmulator({
cols: checkpoint?.cols ?? meta.cols,
rows: checkpoint?.rows ?? meta.rows
})
try {
if (checkpoint) {
if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) {
return null
}
}
for (const batch of log.batches) {
for (const record of batch.records) {
if (record.kind === 'output') {
if (!emulator.writeSync(record.data)) {
return null
}
} else if (record.kind === 'resize') {
emulator.resize(record.cols, record.rows)
} else {
emulator.clearScrollback()
}
}
}
const snapshot = emulator.getSnapshot()
return this.coldRestoreInfoFromSnapshot(
snapshot,
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
meta
)
} catch {
// Why: a replay failure must degrade to checkpoint-only restore, never
// surface as a failed spawn.
return null
} finally {
emulator.dispose()
}
}
private coldRestoreInfoFromSnapshot(
snapshot: {
snapshotAnsi: string
scrollbackAnsi: string
rehydrateSequences: string
cols: number
rows: number
modes: TerminalModes
},
cwd: string | null,
meta: SessionMeta
): ColdRestoreInfo {
// Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi
// (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the
// normal buffer content and is safe to use as scrollback. For alt-screen
// snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY
// stream); return empty instead — the adapter skips cold restore when
// scrollbackAnsi is falsy.
const scrollbackAnsi =
snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi)
return {
snapshotAnsi: snapshot.snapshotAnsi,
scrollbackAnsi,
rehydrateSequences: snapshot.rehydrateSequences,
cwd: cwd ?? meta.cwd,
cols: snapshot.cols,
rows: snapshot.rows,
modes: snapshot.modes
}
}
private readMeta(sessionId: string): SessionMeta | null {
const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json')
if (!existsSync(metaPath)) {
+27
View File
@@ -0,0 +1,27 @@
export function parseFileUriPath(uri: string): string | null {
try {
const url = new URL(uri)
if (url.protocol !== 'file:') {
return null
}
const decodedPath = decodeURIComponent(url.pathname)
if (process.platform !== 'win32') {
return decodedPath
}
// Why: Windows OSC-7 cwd updates can describe both drive-letter paths
// (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the
// hostname when present so live cwd tracking, snapshots, and restore all
// round-trip to a native Windows path instead of dropping the server name.
if (url.hostname) {
return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
}
if (/^\/[A-Za-z]:/.test(decodedPath)) {
return decodedPath.slice(1)
}
return decodedPath.replace(/\//g, '\\')
} catch {
return null
}
}
+109 -55
View File
@@ -5,9 +5,15 @@ import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
resolveAgentForegroundProcessMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
@@ -33,6 +39,10 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
}
})
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
}))
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
@@ -75,6 +85,10 @@ describe('createPtySubprocess', () => {
beforeEach(() => {
spawnMock.mockReset()
isPwshAvailableMock.mockReset()
resolveAgentForegroundProcessMock.mockReset()
resolveAgentForegroundProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
)
validateWorkingDirectoryMock.mockClear()
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
@@ -196,6 +210,93 @@ describe('createPtySubprocess', () => {
expect(handle.getForegroundProcess()).toBe('codex')
})
it('serves daemon wrapper agent foreground from an async cache without blocking', async () => {
const proc = mockPtyProcess()
proc.process = 'node'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('node')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node')
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('serves daemon Windows wrapper agent foreground from an async cache', async () => {
const proc = mockPtyProcess()
proc.process = 'node.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('node.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node.exe')
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not schedule foreground enrichment for arbitrary Windows TUIs', () => {
const proc = mockPtyProcess()
proc.process = 'vim.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('vim.exe')
expect(resolveAgentForegroundProcessMock).not.toHaveBeenCalled()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('treats node-pty terminal name as inconclusive foreground process', () => {
const proc = mockPtyProcess()
proc.process = 'xterm-256color'
@@ -1015,12 +1116,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
['--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -1057,14 +1153,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Debian',
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -1092,14 +1181,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -1127,14 +1209,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({
env: expect.not.objectContaining({
CODEX_HOME: expect.anything(),
@@ -1219,14 +1294,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
expect.objectContaining({
env: expect.objectContaining({
CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home',
@@ -1260,14 +1328,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' })
})
@@ -1350,14 +1411,7 @@ describe('createPtySubprocess', () => {
)
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")],
expect.objectContaining({ cwd: expect.any(String) })
)
})
+138 -1
View File
@@ -27,8 +27,16 @@ import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess
} from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
export type PtySubprocessOptions = {
sessionId: string
@@ -231,6 +239,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string):
return formatted
}
export async function checkPtySpawnHealth(): Promise<void> {
if (process.platform !== 'darwin') {
return
}
ensureNodePtySpawnHelperExecutable()
preflightMacNodePtySpawnEnvironment()
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: getDefaultCwd()
let proc: pty.IPty
try {
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env: {
...process.env,
TERM: 'xterm-256color'
}
})
} catch (err) {
throw formatPtySpawnError(err, '/bin/sh', cwd)
}
await new Promise<void>((resolve, reject) => {
let settled = false
let exitDisposable: { dispose(): void } | undefined
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
exitDisposable?.dispose()
if (opts?.kill) {
try {
proc.kill()
} catch {
// Best-effort cleanup for a short-lived health probe.
}
}
if (error) {
reject(error)
return
}
resolve()
}
const timer = setTimeout(() => {
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
kill: true
})
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
// Why: ping only proves the daemon protocol is alive. A real short-lived
// PTY spawn catches stale node-pty helper paths captured by this process.
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
return
}
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
})
})
}
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
@@ -462,8 +539,52 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let dead = false
let disposed = false
let nodePtyKillIssued = false
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackForegroundProcess = (): string | null =>
normalizeForegroundProcessName(proc.process)
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || !proc.pid) {
return
}
if (
!fallbackProcess ||
isShellProcess(fallbackProcess) ||
recognizeAgentProcess(fallbackProcess) ||
!isAgentForegroundWrapperProcess(fallbackProcess)
) {
return
}
const now = Date.now()
if (
foregroundRefreshInFlight ||
now - lastForegroundRefreshStartedAt < FOREGROUND_AGENT_CACHE_TTL_MS
) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon `getForegroundProcess()` is sync and runs on the IPC hot path.
// Refresh wrapper-derived identities (node/python → codex/gemini/etc.) in
// the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess)
.then((processName) => {
if (dead || !processName || !recognizeAgentProcess(processName)) {
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
})
.finally(() => {
foregroundRefreshInFlight = false
})
}
proc.onExit(() => {
dead = true
cachedAgentForeground = null
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). After the child exits, the master socket's
// 'close' event can fire before our dispose() path gets to neutralize
@@ -489,7 +610,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
return null
}
try {
return normalizeForegroundProcessName(proc.process)
const fallbackProcess = getFallbackForegroundProcess()
if (fallbackProcess && isShellProcess(fallbackProcess)) {
cachedAgentForeground = null
return fallbackProcess
}
if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
return fallbackProcess
}
scheduleAgentForegroundRefresh(fallbackProcess)
if (
cachedAgentForeground &&
Date.now() - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
return fallbackProcess
} catch {
return null
}
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Session } from './session'
// Coverage for the incremental-checkpoint record stream (issue #5096): every
// PTY byte, resize, and clear is recorded so the 5s checkpoint can persist
// increments without serializing the emulator.
function createMockSubprocess() {
let onData: ((data: string) => void) | null = null
return {
pid: 12345,
getForegroundProcess: (): string | null => null,
write(_data: string) {},
resize(_cols: number, _rows: number) {},
kill() {},
forceKill() {},
signal(_sig: string) {},
onData(cb: (data: string) => void) {
onData = cb
},
onExit(_cb: (code: number) => void) {},
dispose() {},
simulateData(data: string) {
onData?.(data)
}
}
}
let session: Session | null = null
afterEach(() => {
session?.dispose()
session = null
})
function createSession(subprocess = createMockSubprocess()): Session {
session = new Session({
sessionId: 'pending-test',
cols: 80,
rows: 24,
subprocess,
shellReadySupported: false
})
return session
}
describe('Session pending output', () => {
it('records output, resize, and clear in application order', () => {
const subprocess = createMockSubprocess()
const live = createSession(subprocess)
subprocess.simulateData('before resize')
live.resize(100, 30)
subprocess.simulateData('after resize')
live.clearScrollback()
const take = live.takePendingOutput(false)
expect(take).not.toBeNull()
expect(take!.overflowed).toBe(false)
expect(take!.snapshot).toBeNull()
expect(take!.records).toEqual([
{ kind: 'output', data: 'before resize' },
{ kind: 'resize', cols: 100, rows: 30 },
{ kind: 'output', data: 'after resize' },
{ kind: 'clear' }
])
})
it('coalesces adjacent output chunks', () => {
const subprocess = createMockSubprocess()
const live = createSession(subprocess)
for (let i = 0; i < 100; i += 1) {
subprocess.simulateData(`chunk-${i};`)
}
const take = live.takePendingOutput(false)
expect(take!.records).toHaveLength(1)
expect(take!.records[0]).toMatchObject({ kind: 'output' })
})
it('drains on take and increments the batch sequence', () => {
const subprocess = createMockSubprocess()
const live = createSession(subprocess)
subprocess.simulateData('first')
const first = live.takePendingOutput(false)
expect(first!.records).toEqual([{ kind: 'output', data: 'first' }])
subprocess.simulateData('second')
const second = live.takePendingOutput(false)
expect(second!.records).toEqual([{ kind: 'output', data: 'second' }])
expect(second!.seq).toBe(first!.seq + 1)
const empty = live.takePendingOutput(false)
expect(empty!.records).toEqual([])
})
it('flags overflow past the cap and recovers after a take', () => {
const subprocess = createMockSubprocess()
const live = createSession(subprocess)
const megabyte = 'x'.repeat(1024 * 1024)
subprocess.simulateData(megabyte)
subprocess.simulateData(megabyte)
subprocess.simulateData(megabyte)
const overflowed = live.takePendingOutput(false)
expect(overflowed!.overflowed).toBe(true)
expect(overflowed!.records).toEqual([])
subprocess.simulateData('post-overflow')
const recovered = live.takePendingOutput(false)
expect(recovered!.overflowed).toBe(false)
expect(recovered!.records).toEqual([{ kind: 'output', data: 'post-overflow' }])
})
it('returns the snapshot and drops records in the same take when requested', () => {
const subprocess = createMockSubprocess()
const live = createSession(subprocess)
subprocess.simulateData('snapshot content\r\n')
const take = live.takePendingOutput(true)
expect(take!.records).toEqual([])
expect(take!.snapshot?.snapshotAnsi).toContain('snapshot content')
// Records taken alongside the snapshot must not reappear later — they are
// already part of the snapshot and would replay twice on cold restore.
const next = live.takePendingOutput(false)
expect(next!.records).toEqual([])
})
it('returns null after dispose', () => {
const live = createSession()
live.dispose()
expect(live.takePendingOutput(false)).toBeNull()
})
})
+70 -1
View File
@@ -2,11 +2,26 @@
import { HeadlessEmulator } from './headless-emulator'
import { isValidPtySize, normalizePtySize } from './daemon-pty-size'
import { PostReadyFlushGate } from './post-ready-flush-gate'
import type { SessionState, ShellReadyState, TerminalSnapshot } from './types'
import type {
PendingOutputRecord,
SessionState,
ShellReadyState,
TakePendingOutputResult,
TerminalSnapshot
} from './types'
const SHELL_READY_TIMEOUT_MS = 15_000
const KILL_TIMEOUT_MS = 5_000
const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready\x07'
// Why: pending records exist so the 5s checkpoint can persist increments
// instead of re-serializing the whole buffer. If no client drains them (main
// process gone, history disabled), memory must stay bounded — past the cap we
// drop the records and flag overflow so the next take falls back to one full
// snapshot, which subsumes everything dropped.
// Counted in UTF-16 code units (string .length), which tracks JS heap cost.
// Worst-case wire size for a full take is ~6x this (each control char
// JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB).
const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
export type SubprocessHandle = {
pid: number
@@ -56,6 +71,10 @@ export class Session {
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | null = null
private postReadyFlushGate: PostReadyFlushGate
private pendingOutputRecords: PendingOutputRecord[] = []
private pendingOutputBytes = 0
private pendingOutputOverflowed = false
private pendingOutputSeq = 0
constructor(opts: SessionOptions) {
this.sessionId = opts.sessionId
@@ -134,6 +153,9 @@ export class Session {
return
}
this.emulator.resize(cols, rows)
// Why: the record stream must mirror the order operations were applied to
// the emulator, or cold-restore replay reflows at the wrong point.
this.recordPendingOutput({ kind: 'resize', cols, rows })
this.subprocess.resize(cols, rows)
}
@@ -183,6 +205,28 @@ export class Session {
return this.emulator.getSnapshot()
}
/** Drains the records accumulated since the last take. Runs synchronously —
* when includeSnapshot is set, the serialize happens in the same turn so no
* PTY data can land between the drain and the snapshot (which would later
* be replayed twice on cold restore). */
takePendingOutput(includeSnapshot: boolean): TakePendingOutputResult | null {
if (this._disposed) {
return null
}
const records = this.pendingOutputRecords
const overflowed = this.pendingOutputOverflowed
this.pendingOutputRecords = []
this.pendingOutputBytes = 0
this.pendingOutputOverflowed = false
this.pendingOutputSeq += 1
return {
records: includeSnapshot ? [] : records,
seq: this.pendingOutputSeq,
overflowed,
snapshot: includeSnapshot ? this.emulator.getSnapshot() : null
}
}
getCwd(): string | null {
return this.emulator.getCwd()
}
@@ -196,6 +240,7 @@ export class Session {
return
}
this.emulator.clearScrollback()
this.recordPendingOutput({ kind: 'clear' })
}
dispose(): void {
@@ -301,6 +346,29 @@ export class Session {
}
}
private recordPendingOutput(record: PendingOutputRecord): void {
if (this.pendingOutputOverflowed) {
return
}
const bytes = record.kind === 'output' ? record.data.length : 8
if (this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES) {
this.pendingOutputRecords = []
this.pendingOutputBytes = 0
this.pendingOutputOverflowed = true
return
}
// Why: TUIs emit thousands of tiny chunks between checkpoint ticks;
// coalescing adjacent output keeps the take RPC and log frames compact.
// The 64KB segment cap bounds per-chunk string-append cost.
const last = this.pendingOutputRecords.at(-1)
if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) {
last.data += record.data
} else {
this.pendingOutputRecords.push(record)
}
this.pendingOutputBytes += bytes
}
private handleSubprocessData(data: string): void {
if (this._disposed) {
return
@@ -308,6 +376,7 @@ export class Session {
// Feed data to headless emulator for state tracking
this.emulator.write(data)
this.recordPendingOutput({ kind: 'output', data })
if (this._shellState === 'pending') {
this.scanForShellMarker(data)
+224
View File
@@ -7,6 +7,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import type * as ShellReadyModule from './shell-ready'
import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates'
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
vi.resetModules()
@@ -16,6 +17,96 @@ async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
const itWithZsh = hasZsh ? it : it.skip
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
// Why: the shell-ready marker is emitted from zle-line-init, which only fires
// on a real TTY — spawn through node-pty instead of spawnSync.
async function runInteractiveZshLogin(args: {
tempHome: string
wrapperZdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/* on CI runners, whose insecure (group-
// writable) fpath dirs make the global compinit block on an interactive
// "insecure directories" [y/n] prompt before zle-line-init ever fires. The
// marker contract lives entirely in our ZDOTDIR files, which still load.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: args.tempHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: args.tempHome,
TERM: 'xterm-256color',
ZDOTDIR: args.wrapperZdotdir,
ORCA_ORIG_ZDOTDIR: args.tempHome,
ORCA_ZSHENV_SOURCE_DIR: args.tempHome,
ORCA_SHELL_READY_MARKER: '1'
}
})
let output = ''
let settle = (): void => {}
const done = new Promise<void>((resolve) => {
settle = resolve
})
const deadline = setTimeout(settle, 10_000)
proc.onData((chunk) => {
output += chunk
if (args.isDone(output)) {
settle()
}
})
await done
clearTimeout(deadline)
proc.kill()
return output
}
// Why: exercise an arbitrary interactive zsh rc (its own ZDOTDIR, no wrapper)
// so a test can source the marker block directly — e.g. twice, to check the
// registration is idempotent and keeps chaining the user's prior widget.
async function runInteractiveZshRc(args: {
zdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit
// can't block on an insecure-directory [y/n] prompt before our marker fires.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: args.zdotdir,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: args.zdotdir,
TERM: 'xterm-256color',
ZDOTDIR: args.zdotdir,
ORCA_SHELL_READY_MARKER: '1'
}
})
let output = ''
let settle = (): void => {}
const done = new Promise<void>((resolve) => {
settle = resolve
})
const deadline = setTimeout(settle, 10_000)
proc.onData((chunk) => {
output += chunk
if (args.isDone(output)) {
settle()
}
})
await done
clearTimeout(deadline)
proc.kill()
return output
}
function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string {
const rcfile = join(tempDir, 'bash-osc133-rcfile')
@@ -237,6 +328,139 @@ describePosix('daemon shell-ready launch config', () => {
expectFinalZdotdirRestoreContext(zlogin)
})
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
// Why: add-zle-hook-widget aborts its hook chain when an earlier hook
// exits non-zero, so the marker must not be registered through it.
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
// Why: re-source guard — skip re-capturing when we are already the bound
// widget so the prior widget chain survives a second source.
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
})
// Why: regression guard — oh-my-zsh vi-mode installs a raw zle-line-init
// that returns non-zero when VI_MODE_SET_CURSOR is unset. Registering the
// marker via add-zle-hook-widget let that failing widget abort the hook
// chain, so the marker never fired and every queued startup command sat on
// the daemon's pre-ready timeout (a 15s "bare shell" before the agent).
itWithZsh(
'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)',
async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-vi-mode-'))
writeFileSync(
join(tempHome, '.zshrc'),
[
'function zle-line-init() {',
' [[ "${VI_MODE_SET_CURSOR:-}" = true ]] || return',
'}',
'zle -N zle-line-init',
''
].join('\n')
)
try {
const output = await runInteractiveZshLogin({
tempHome,
wrapperZdotdir: config.env.ZDOTDIR,
isDone: (current) => current.includes(SHELL_READY_MARKER_OUTPUT)
})
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
} finally {
rmSync(tempHome, { recursive: true, force: true })
}
},
15_000
)
itWithZsh(
'still runs user add-zle-hook-widget line-init hooks after the marker',
async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-azhw-'))
const userHookOutput = 'ORCA-TEST-USER-HOOK'
writeFileSync(
join(tempHome, '.zshrc'),
[
`__orca_test_line_init_hook() { printf "${userHookOutput}" }`,
'autoload -Uz add-zle-hook-widget',
'zle -N __orca_test_line_init_hook',
'add-zle-hook-widget line-init __orca_test_line_init_hook',
''
].join('\n')
)
try {
const output = await runInteractiveZshLogin({
tempHome,
wrapperZdotdir: config.env.ZDOTDIR,
isDone: (current) =>
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
})
// Why: the marker widget chains to the previously installed widget, so
// an azhw dispatcher registered by user config must keep dispatching.
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output).toContain(userHookOutput)
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
output.indexOf(userHookOutput)
)
} finally {
rmSync(tempHome, { recursive: true, force: true })
}
},
15_000
)
// Why: the marker block is normally sourced once per shell, but a re-source
// (nested Orca, manual re-source) must stay idempotent — it must keep
// chaining the user's original zle-line-init instead of clobbering the
// captured function to empty and silently dropping it on later prompts.
itWithZsh(
'keeps chaining the prior zle-line-init widget when the marker block is sourced twice',
async () => {
const zdotdir = mkdtempSync(join(tmpdir(), 'orca-zsh-resource-'))
const userHookOutput = 'ORCA-TEST-PRIOR-WIDGET'
const block = getZshShellReadyMarkerRegistrationBlock('\\033]777;orca-shell-ready\\007')
writeFileSync(
join(zdotdir, '.zshrc'),
[
// A user widget that mimics oh-my-zsh vi-mode owning zle-line-init.
`__orca_test_prior_widget() { printf "${userHookOutput}" }`,
'zle -N zle-line-init __orca_test_prior_widget',
block,
// Second source of the exact same block — must not drop the chain.
block,
''
].join('\n')
)
try {
const output = await runInteractiveZshRc({
zdotdir,
isDone: (current) =>
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
})
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output).toContain(userHookOutput)
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
output.indexOf(userHookOutput)
)
// Why: idempotent — the marker must fire exactly once per prompt, not
// duplicated by the second registration.
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
} finally {
rmSync(zdotdir, { recursive: true, force: true })
}
},
15_000
)
it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
+3 -11
View File
@@ -14,6 +14,7 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
getZshStartupFileSourceBlock
} from '../shell-templates'
@@ -111,7 +112,7 @@ __orca_restore_agent_teams_path() {
}
__orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can
# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only.
@@ -305,16 +306,7 @@ if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}
fi
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
__orca_prompt_mark() {
printf "${SHELL_READY_MARKER}"
}
# Why: zsh precmd fires before zle switches the PTY into line-editing mode,
# so writing startup input there can be echoed once outside the prompt.
autoload -Uz add-zle-hook-widget
zle -N __orca_prompt_mark
add-zle-hook-widget line-init __orca_prompt_mark
fi
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
${getZshFinalZdotdirRestoreBlock()}
`
const bashRc = getDaemonBashShellReadyRcfileContent()
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, createServer, type Server, type Socket } from 'net'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync } from 'fs'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { healthCheckDaemon } from './daemon-health'
import type { ListSessionsResult } from './types'
import type { SubprocessHandle } from './session'
// Why: terminals were lost after app updates because a busy machine could
// time out the 3s startup health check against a daemon that was alive and
// owning sessions, and the unhealthy path killed it. The fix re-verifies
// with listSessions, which has far larger budgets (5s hello, 30s request).
// This test reproduces the production asymmetry against a REAL daemon by
// inserting a response delay that exceeds the health-check budget but fits
// the verification budgets, and asserts the guard's two inputs disagree the
// way the fix depends on.
const RESPONSE_DELAY_MS = 3_500
function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
forceKill: vi.fn(),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn(),
dispose: vi.fn()
}
}
/** Forwards client bytes to the daemon immediately, but delays every daemon
* response so each round-trip looks like a daemon under heavy load. */
function startDelayProxy(listenPath: string, upstreamPath: string): Server {
const proxy = createServer((clientSocket: Socket) => {
const upstream = connect(upstreamPath)
clientSocket.on('data', (chunk) => upstream.write(chunk))
upstream.on('data', (chunk) => {
setTimeout(() => {
if (!clientSocket.destroyed) {
clientSocket.write(chunk)
}
}, RESPONSE_DELAY_MS)
})
const teardown = (): void => {
clientSocket.destroy()
upstream.destroy()
}
clientSocket.on('close', teardown)
clientSocket.on('error', teardown)
upstream.on('close', () => {
setTimeout(teardown, RESPONSE_DELAY_MS)
})
upstream.on('error', teardown)
})
proxy.listen(listenPath)
return proxy
}
describe('slow daemon session verification', () => {
let dir: string
let daemonSocketPath: string
let proxySocketPath: string
let tokenPath: string
let server: DaemonServer
let proxy: Server
const clients: DaemonClient[] = []
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-slow-verification-test-'))
daemonSocketPath = join(dir, 'daemon.sock')
proxySocketPath = join(dir, 'proxy.sock')
tokenPath = join(dir, 'daemon.token')
})
afterEach(async () => {
for (const client of clients.splice(0)) {
client.disconnect()
}
await new Promise<void>((resolve) => proxy?.close(() => resolve()))
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it(
'fails the 3s health check against a slow daemon while listSessions still verifies its live session',
{ timeout: 60_000 },
async () => {
server = new DaemonServer({
socketPath: daemonSocketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const directClient = new DaemonClient({ socketPath: daemonSocketPath, tokenPath })
clients.push(directClient)
await directClient.ensureConnected()
await directClient.request('createOrAttach', {
sessionId: 'wt-1@@live-session',
cols: 80,
rows: 24
})
proxy = startDelayProxy(proxySocketPath, daemonSocketPath)
// The exact pre-fix kill trigger: the daemon is alive but too slow for
// the health-check budget.
await expect(healthCheckDaemon(proxySocketPath, tokenPath)).resolves.toBe(false)
// The fix's re-verification against the SAME slow daemon: the larger
// client budgets absorb the latency and prove the session is alive.
const verificationClient = new DaemonClient({ socketPath: proxySocketPath, tokenPath })
clients.push(verificationClient)
await verificationClient.ensureConnected()
const result = await verificationClient.request<ListSessionsResult>('listSessions', undefined)
const liveSessionCount = result.sessions.filter((session) => session.isAlive).length
expect(liveSessionCount).toBe(1)
}
)
})
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, truncateSync } from 'fs'
import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import { HeadlessEmulator } from './headless-emulator'
import { encodeLogBatch, encodeLogHeader } from './terminal-history-log'
import { getHistorySessionDirName } from './history-paths'
import type { PendingOutputRecord } from './types'
// End-to-end coverage for incremental checkpoint persistence and cold-restore
// replay (issue #5096): HistoryManager appends take batches to output.log;
// HistoryReader replays checkpoint base + log tail through a scratch emulator.
const SESSION_ID = 'wt@@incremental-test'
let dir: string
let manager: HistoryManager
let reader: HistoryReader
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-incremental-restore-'))
manager = new HistoryManager(dir)
reader = new HistoryReader(dir)
await manager.openSession(SESSION_ID, { cwd: '/home/user', cols: 80, rows: 24 })
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
function sessionFile(name: string): string {
return join(dir, getHistorySessionDirName(SESSION_ID), name)
}
function snapshotOf(writes: string[], cols = 80, rows = 24) {
const emulator = new HeadlessEmulator({ cols, rows })
try {
for (const data of writes) {
emulator.writeSync(data)
}
return emulator.getSnapshot()
} finally {
emulator.dispose()
}
}
describe('incremental terminal history restore', () => {
it('replays appended output with no checkpoint base', async () => {
await manager.appendIncrements(SESSION_ID, 1, [
{ kind: 'output', data: 'first line\r\n' },
{ kind: 'output', data: 'second line\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('first line')
expect(restore!.scrollbackAnsi).toContain('second line')
expect(restore!.cwd).toBe('/home/user')
})
it('replays checkpoint base plus log tail', async () => {
await manager.checkpoint(SESSION_ID, snapshotOf(['from base\r\n']))
await manager.appendIncrements(SESSION_ID, 1, [
{ kind: 'output', data: 'from tail after checkpoint\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('from base')
expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint')
})
it('ignores a stale log whose generation predates the checkpoint', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'stale tail\r\n' }])
// Simulate a crash between checkpoint rename and log reset: write the
// checkpoint with a newer generation while the gen-0 log stays on disk.
const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['base content\r\n'])))
writeFileSync(
sessionFile('checkpoint.json'),
JSON.stringify({ ...checkpoint, cwd: '/home/user', generation: 1 })
)
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('base content')
expect(restore!.scrollbackAnsi).not.toContain('stale tail')
})
it('ignores a log when the checkpoint has no generation (pre-log format)', async () => {
const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['old format base\r\n'])))
writeFileSync(
sessionFile('checkpoint.json'),
JSON.stringify({ ...checkpoint, cwd: '/home/user' })
)
writeFileSync(
sessionFile('output.log'),
Buffer.concat([
encodeLogHeader(0),
encodeLogBatch(1, [{ kind: 'output', data: 'orphan tail\r\n' }])
])
)
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('old format base')
expect(restore!.scrollbackAnsi).not.toContain('orphan tail')
})
it('falls back to the checkpoint when the log has a sequence gap', async () => {
await manager.checkpoint(SESSION_ID, snapshotOf(['safe base\r\n']))
const generation = 1
writeFileSync(
sessionFile('output.log'),
Buffer.concat([
encodeLogHeader(generation),
encodeLogBatch(1, [{ kind: 'output', data: 'kept\r\n' }]),
encodeLogBatch(3, [{ kind: 'output', data: 'after gap\r\n' }])
])
)
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('safe base')
expect(restore!.scrollbackAnsi).not.toContain('kept')
expect(restore!.scrollbackAnsi).not.toContain('after gap')
})
it('replays the complete prefix of a torn final append', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'complete batch\r\n' }])
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'torn batch\r\n' }])
const logPath = sessionFile('output.log')
truncateSync(logPath, readFileSync(logPath).length - 5)
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('complete batch')
expect(restore!.scrollbackAnsi).not.toContain('torn batch')
})
it('applies resize records during replay', async () => {
await manager.appendIncrements(SESSION_ID, 1, [
{ kind: 'output', data: 'before resize\r\n' },
{ kind: 'resize', cols: 132, rows: 40 },
{ kind: 'output', data: 'after resize\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.cols).toBe(132)
expect(restore!.rows).toBe(40)
expect(restore!.scrollbackAnsi).toContain('after resize')
})
it('applies clear records during replay', async () => {
await manager.appendIncrements(SESSION_ID, 1, [
{ kind: 'output', data: 'cleared away\r\n' },
{ kind: 'clear' },
{ kind: 'output', data: 'survives clear\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('survives clear')
expect(restore!.scrollbackAnsi).not.toContain('cleared away')
})
it('skips restorable content for sessions crashed inside the alt screen', async () => {
await manager.appendIncrements(SESSION_ID, 1, [
{ kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.modes.alternateScreen).toBe(true)
// Why: the adapter skips cold restore when scrollbackAnsi is empty — alt
// buffer contents must not replay into a fresh shell.
expect(restore!.scrollbackAnsi).toBe('')
})
it('resets the log on checkpoint so old records are not replayed twice', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'pre-checkpoint\r\n' }])
await manager.checkpoint(SESSION_ID, snapshotOf(['pre-checkpoint\r\n']))
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'post-checkpoint\r\n' }])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
const occurrences = restore!.scrollbackAnsi.split('pre-checkpoint').length - 1
expect(occurrences).toBe(1)
expect(restore!.scrollbackAnsi).toContain('post-checkpoint')
})
it('requests a full checkpoint when the log reaches its cap', async () => {
const bigRecord: PendingOutputRecord = {
kind: 'output',
data: 'x'.repeat(2 * 1024 * 1024)
}
expect(await manager.appendIncrements(SESSION_ID, 1, [bigRecord])).toBe('ok')
expect(await manager.appendIncrements(SESSION_ID, 2, [bigRecord])).toBe('ok')
expect(await manager.appendIncrements(SESSION_ID, 3, [bigRecord])).toBe('needs-checkpoint')
// Why: the rejected batch is subsumed by the snapshot the caller takes
// next; checkpoint() resets the log for the new generation.
await manager.checkpoint(SESSION_ID, snapshotOf(['compacted\r\n']))
expect(
await manager.appendIncrements(SESSION_ID, 4, [{ kind: 'output', data: 'fresh\r\n' }])
).toBe('ok')
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('compacted')
expect(restore!.scrollbackAnsi).toContain('fresh')
})
it('openSession removes a stale log from a previous session with the same id', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'old session\r\n' }])
expect(existsSync(sessionFile('output.log'))).toBe(true)
await manager.openSession(SESSION_ID, { cwd: '/home/user', cols: 80, rows: 24 })
expect(existsSync(sessionFile('output.log'))).toBe(false)
})
it('continues an existing log after a warm registerWriter', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'before relaunch\r\n' }])
// Simulate app relaunch: a fresh HistoryManager attaches to the same dir.
const relaunched = new HistoryManager(dir)
relaunched.registerWriter(SESSION_ID)
await relaunched.appendIncrements(SESSION_ID, 2, [
{ kind: 'output', data: 'after relaunch\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('before relaunch')
expect(restore!.scrollbackAnsi).toContain('after relaunch')
})
})
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
decodeLogHeader,
decodeTerminalHistoryLog,
encodeLogBatch,
encodeLogHeader,
LOG_HEADER_BYTES
} from './terminal-history-log'
import type { PendingOutputRecord } from './types'
function buildLog(generation: number, batches: { seq: number; records: PendingOutputRecord[] }[]) {
return Buffer.concat([
encodeLogHeader(generation),
...batches.map((batch) => encodeLogBatch(batch.seq, batch.records))
])
}
describe('terminal history log codec', () => {
it('round-trips header generation', () => {
expect(decodeLogHeader(encodeLogHeader(0))).toBe(0)
expect(decodeLogHeader(encodeLogHeader(42))).toBe(42)
})
it('rejects bad magic and unknown format versions', () => {
expect(decodeLogHeader(Buffer.from('NOPE\x01\x00\x00\x00\x00', 'latin1'))).toBeNull()
const wrongVersion = encodeLogHeader(1)
wrongVersion.writeUInt8(99, 4)
expect(decodeLogHeader(wrongVersion)).toBeNull()
expect(decodeTerminalHistoryLog(wrongVersion)).toBeNull()
expect(decodeLogHeader(Buffer.alloc(3))).toBeNull()
})
it('round-trips output, resize, and clear records', () => {
const records: PendingOutputRecord[] = [
{ kind: 'output', data: 'hello \x1b[31mred\x1b[0m — émoji 🐳\r\n' },
{ kind: 'resize', cols: 132, rows: 43 },
{ kind: 'clear' },
{ kind: 'output', data: 'after clear' }
]
const log = decodeTerminalHistoryLog(buildLog(7, [{ seq: 3, records }]))
expect(log).not.toBeNull()
expect(log!.generation).toBe(7)
expect(log!.truncatedTail).toBe(false)
expect(log!.batches).toEqual([{ seq: 3, records }])
})
it('decodes multiple contiguous batches', () => {
const log = decodeTerminalHistoryLog(
buildLog(1, [
{ seq: 5, records: [{ kind: 'output', data: 'a' }] },
{ seq: 6, records: [{ kind: 'output', data: 'b' }] },
{ seq: 7, records: [] }
])
)
expect(log!.batches.map((batch) => batch.seq)).toEqual([5, 6, 7])
})
it('rejects the whole log on a batch sequence gap', () => {
// Why: a gap means an appended take batch was lost (e.g. main crashed
// between take and append); the byte stream has a hole, so replaying any
// of it would corrupt the restored terminal.
const log = decodeTerminalHistoryLog(
buildLog(1, [
{ seq: 5, records: [{ kind: 'output', data: 'a' }] },
{ seq: 7, records: [{ kind: 'output', data: 'b' }] }
])
)
expect(log).toBeNull()
})
it('truncates a torn final frame and keeps the complete prefix', () => {
const full = buildLog(2, [
{ seq: 1, records: [{ kind: 'output', data: 'complete' }] },
{ seq: 2, records: [{ kind: 'output', data: 'torn-away-tail' }] }
])
for (const cut of [1, 3, 7] as const) {
const torn = full.subarray(0, full.length - cut)
const log = decodeTerminalHistoryLog(torn)
expect(log).not.toBeNull()
expect(log!.truncatedTail).toBe(true)
expect(log!.batches[0]).toEqual({
seq: 1,
records: [{ kind: 'output', data: 'complete' }]
})
}
})
it('treats a record frame before any batch frame as unreadable', () => {
const orphanRecord = Buffer.concat([
encodeLogHeader(0),
// encodeLogBatch always prefixes a batch frame; slice it off to craft
// a stream that starts with a bare output frame.
encodeLogBatch(1, [{ kind: 'output', data: 'x' }]).subarray(9)
])
expect(decodeTerminalHistoryLog(orphanRecord)).toBeNull()
})
it('decodes an empty log (header only)', () => {
const log = decodeTerminalHistoryLog(encodeLogHeader(4))
expect(log).toEqual({ generation: 4, batches: [], truncatedTail: false })
expect(LOG_HEADER_BYTES).toBe(encodeLogHeader(4).length)
})
})

Some files were not shown because too many files have changed in this diff Show More