Merge remote-tracking branch 'origin/main' into Jinwoo-H/feature-control-multiple-orca-servers-concurrent

This commit is contained in:
Jinwoo-H
2026-06-12 17:17:27 -07:00
495 changed files with 35128 additions and 11674 deletions
+1
View File
@@ -36,6 +36,7 @@ export const BOOLEAN_FLAGS = new Set([
'mobile',
'mobile-pairing',
'no-pairing',
'parent-current',
'ready',
'relations',
'restore-window',
+233
View File
@@ -182,6 +182,169 @@ describe('orca linear CLI handlers', () => {
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('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',
teamKey: undefined,
parentInput: undefined,
parentCurrent: true,
workspaceId: undefined,
writeId: undefined,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ 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 {
@@ -213,3 +376,73 @@ function issueResult(): unknown {
}
}
}
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 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)
}
}
}
}
+200 -9
View File
@@ -1,11 +1,23 @@
import { readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import type {
LinearAttachRequest,
LinearAttachResult,
LinearCommentAddRequest,
LinearCommentAddResult,
LinearCreateRequest,
LinearCreateResult,
LinearIssueContextResult,
LinearIssueInclude,
LinearIssueRequest,
LinearSearchResult
LinearSearchResult,
LinearStatusSetRequest,
LinearStatusSetResult,
LinearWriteTargetRequest
} from '../../shared/linear-agent-access'
import {
LINEAR_CHILDREN_MAX_DEPTH,
LINEAR_WRITE_BODY_CAP,
clampLinearIssueDepth,
clampLinearSearchLimit
} from '../../shared/linear-agent-access'
@@ -15,17 +27,24 @@ import {
getOptionalNonNegativeIntegerFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlagAllowingEmpty,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import {
formatLinearAttach,
formatLinearCommentAdd,
formatLinearCreate,
formatLinearIssue,
formatLinearSearch,
formatLinearStatusSet,
printLinearIssueWarnings,
printLinearSearchWarnings
} from '../linear-format'
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
const LINEAR_WRITE_TIMEOUT_MS = 75_000
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
'linear issue': async ({ flags, client, cwd, json }) => {
@@ -49,6 +68,67 @@ export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
printLinearSearchWarnings(response.result)
}
printResult(response, json, formatLinearSearch)
},
'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 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 } : {}),
teamKey: getOptionalStringFlag(flags, 'team'),
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)
}
}
@@ -88,13 +168,124 @@ function buildIssueRequest(
workspaceId,
include: includes,
depth: clampLinearIssueDepth(requestedDepth),
context: {
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 }
: {})
}
context: buildLinearCurrentContext(cwd, remote)
}
}
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)
}
}
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 }
: {})
}
}
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'
)
}
}
function getOptionalWriteId(flags: Map<string, string | boolean>): string | undefined {
if (!flags.has('write-id')) {
return undefined
}
const writeId = getRequiredStringFlag(flags, 'write-id')
if (!UUID_PATTERN.test(writeId)) {
throw new RuntimeClientError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
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')
}
function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: true }
): Promise<string>
function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: false }
): Promise<string | undefined>
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')
}
+36
View File
@@ -387,6 +387,42 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
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'
}
+61
View File
@@ -196,6 +196,67 @@ describe('orca root help', () => {
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
expect(callMock).not.toHaveBeenCalled()
})
it('progressively discloses Linear commands', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['--help'], '/tmp/repo')
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).toContain('Linear:')
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
expect(rootHelp).not.toContain('linear issue')
expect(rootHelp).not.toContain('linear search')
expect(rootHelp).not.toContain('linear status set')
logSpy.mockClear()
await main(['linear', '--help'], '/tmp/repo')
const groupHelp = String(logSpy.mock.calls[0][0])
expect(groupHelp).toContain('orca linear')
expect(groupHelp).toContain('issue')
expect(groupHelp).toContain('search')
expect(groupHelp).toContain('status set')
expect(groupHelp).toContain('comment add')
expect(groupHelp).toContain('attach')
expect(groupHelp).toContain('create')
expect(groupHelp).not.toContain('--comments')
expect(groupHelp).not.toContain('--attachments')
logSpy.mockClear()
await main(['linear', 'issue', '--help'], '/tmp/repo')
const issueHelp = String(logSpy.mock.calls[0][0])
expect(issueHelp).toContain('orca linear issue [<id>]')
expect(issueHelp).toContain('--comments Include threaded Linear comments')
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
logSpy.mockClear()
await main(['linear', 'search', '--help'], '/tmp/repo')
const searchHelp = String(logSpy.mock.calls[0][0])
expect(searchHelp).toContain('orca linear search <query>')
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
logSpy.mockClear()
await main(['linear', 'comment', 'add', '--help'], '/tmp/repo')
const commentHelp = String(logSpy.mock.calls[0][0])
expect(commentHelp).toContain('orca linear comment add [<id>]')
expect(commentHelp).toContain('--body-file <path|-> Read Linear body from a file or stdin')
expect(commentHelp).toContain('--write-id <uuid> Retry id from linear_write_unconfirmed')
logSpy.mockClear()
await main(['linear', 'create', '--help'], '/tmp/repo')
const createHelp = String(logSpy.mock.calls[0][0])
expect(createHelp).toContain('orca linear create --title <title>')
expect(createHelp).toContain('--parent-current Use the current linked issue as parent')
expect(callMock).not.toHaveBeenCalled()
})
})
describe('orca cli worktree awareness', () => {
+26 -1
View File
@@ -1,7 +1,11 @@
import type {
LinearAttachResult,
LinearCommentAddResult,
LinearCreateResult,
LinearIssueContextResult,
LinearSearchIssueSummary,
LinearSearchResult
LinearSearchResult,
LinearStatusSetResult
} from '../shared/linear-agent-access'
export function formatLinearIssue(result: LinearIssueContextResult): string {
@@ -44,6 +48,27 @@ export function formatLinearSearch(result: LinearSearchResult): string {
return result.issues.map(formatSearchRow).join('\n')
}
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 suffix = result.meta.deduplicated ? ' (already created)' : ''
return `Created ${result.issue.identifier}${parent}: ${result.issue.title}${suffix}.`
}
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
for (const error of result.meta.includeErrors) {
console.error(`warning: ${error.include} unavailable: ${error.message}`)
+67
View File
@@ -33,5 +33,72 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
positionalArgs: ['query'],
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --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', '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>] [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'title',
'body',
'body-file',
'team',
'parent',
'parent-current',
'write-id',
'workspace'
],
examples: [
'orca linear create --title "Investigate flaky login" --team ENG',
'orca linear create --title "Follow-up bug" --parent-current --body-file - --json'
],
notes: ['Use --body-file - to read multiline issue bodies from stdin.']
}
]
@@ -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(' '))
}
@@ -13,6 +13,7 @@ export type AiVaultScanOptions = {
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
grokSessionsDir?: string
hermesSessionsDir?: string
rovoSessionsDir?: string
openclawStateDir?: string
+38
View File
@@ -20,6 +20,7 @@ function isolatedScanRoots(root: string) {
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'),
@@ -422,6 +423,42 @@ describe('scanAiVaultSessions', () => {
})
)
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'),
@@ -544,6 +581,7 @@ describe('scanAiVaultSessions', () => {
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'"
)
+17 -4
View File
@@ -8,6 +8,7 @@ import type {
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,
@@ -53,15 +54,17 @@ 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 OPENCLAW_LEGACY_STATE_DIR = join(homedir(), '.clawdbot')
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')
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
@@ -121,6 +124,14 @@ export async function scanAiVaultSessions(
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,
@@ -140,7 +151,7 @@ export async function scanAiVaultSessions(
discoverOpenClawFiles({
rootDirs: [
options.openclawStateDir ?? OPENCLAW_STATE_DIR,
options.openclawLegacyStateDir ?? OPENCLAW_LEGACY_STATE_DIR
options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot')
],
limit: limitPerAgent,
issues
@@ -160,7 +171,7 @@ export async function scanAiVaultSessions(
extensions: ['.jsonl']
}),
discoverFiles({
rootDir: options.droidProjectsDir ?? DROID_PROJECTS_DIR,
rootDir: options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'),
limit: limitPerAgent,
agent: 'droid',
issues,
@@ -274,6 +285,8 @@ async function parseAgentSessionFile(
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':
@@ -2872,7 +2872,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 +2912,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 () => {
@@ -211,8 +211,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'
)
}
}
+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
+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 -13
View File
@@ -147,19 +147,7 @@ export function checkDaemonHealth(
}
if (message.id === 'health-1') {
if (!message.ok) {
settle('unhealthy')
return
}
// Why: protocol ping only proves the socket loop is alive. New
// terminals also depend on node-pty's native helper state inside
// the daemon process, which can go stale after dev rebuilds.
sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' }))
continue
}
if (message.id === 'health-2') {
settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy')
settle(message.ok === true)
return
}
}
+49 -4
View File
@@ -275,6 +275,7 @@ async function importFresh() {
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
healthCheckDaemonMock.mockResolvedValue(true)
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
getDaemonLaunchIdentityMock.mockClear()
@@ -1173,7 +1174,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(child.unref).not.toHaveBeenCalled()
})
it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => {
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1198,7 +1199,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
await launcher('/fake/socket', '/fake/token')
@@ -1208,7 +1209,51 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(forkMock).not.toHaveBeenCalled()
})
it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => {
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()
@@ -1216,7 +1261,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
+37 -9
View File
@@ -189,15 +189,21 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
console.warn('[daemon] Replacing daemon that cannot spawn new PTYs')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
// Why: a protocol-healthy daemon can outlive the app bundle that
// 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 = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
app.isPackaged &&
(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.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
@@ -245,6 +251,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
}
}
} 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
@@ -367,6 +387,14 @@ 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({
+95 -45
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(() => ({
@@ -58,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()) {
@@ -536,7 +514,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
@@ -550,22 +528,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
}
@@ -577,30 +566,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)
@@ -619,7 +616,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 () => {
@@ -750,6 +748,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))
+84 -15
View File
@@ -15,7 +15,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'
@@ -71,11 +72,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
private coldRestoreCache = new Map<string, { scrollback: string; cwd: 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) {
@@ -91,6 +101,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 {
@@ -188,6 +199,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 (scrollback) {
const coldRestore = { scrollback, cwd: restoreInfo.cwd }
@@ -615,17 +627,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
@@ -641,16 +658,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))
}
@@ -665,6 +675,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,
+11 -11
View File
@@ -198,19 +198,19 @@ describe('DaemonServer', () => {
expect(result).toEqual({ pong: true })
})
it('handles ptySpawnHealth through the daemon process', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
it('replies with an error to unknown request types and keeps serving', async () => {
await startServer()
const c = await connectClient()
await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true })
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
// Why: older app builds still send the removed ptySpawnHealth probe.
// The daemon must reject it gracefully so a downgraded client lands on
// its session-preserving branch instead of crashing the daemon.
await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow(
'Unknown request type: ptySpawnHealth'
)
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
pong: true
})
})
it('handles systemResolverHealth', async () => {
+10
View File
@@ -373,6 +373,16 @@ 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 }
@@ -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
+135 -10
View File
@@ -6,10 +6,26 @@ import {
existsSync,
rmSync,
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,13 +125,57 @@ 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
})
}
/** 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
@@ -126,7 +196,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,
@@ -135,23 +207,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. Async IO — this runs every ~5s per dirty session
// on the Electron main process, and a sync ~MB write (worse under
// 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`
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
}
}
+50 -57
View File
@@ -9,11 +9,11 @@ const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
getNodePtySpawnHelperCandidatesMock
resolveAgentForegroundProcessMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
getNodePtySpawnHelperCandidatesMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
@@ -40,7 +40,11 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
}
})
import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess'
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
}))
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
@@ -82,6 +86,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
@@ -255,6 +263,38 @@ 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('treats node-pty terminal name as inconclusive foreground process', () => {
const proc = mockPtyProcess()
proc.process = 'xterm-256color'
@@ -1074,12 +1114,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) })
)
})
@@ -1116,14 +1151,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) })
)
})
@@ -1151,14 +1179,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) })
)
})
@@ -1186,14 +1207,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(),
@@ -1278,14 +1292,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',
@@ -1319,14 +1326,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' })
})
@@ -1409,14 +1409,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) })
)
})
+64 -2
View File
@@ -27,9 +27,12 @@ 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 { 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 PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
export type PtySubprocessOptions = {
sessionId: string
@@ -532,8 +535,51 @@ 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 || process.platform === 'win32' || !proc.pid) {
return
}
if (
!fallbackProcess ||
isShellProcess(fallbackProcess) ||
recognizeAgentProcess(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
@@ -559,7 +605,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)
@@ -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)
})
})
+165
View File
@@ -0,0 +1,165 @@
import type { PendingOutputRecord } from './types'
// On-disk framing for the incremental terminal history log (output.log).
//
// Layout: header, then batch frames appended every checkpoint tick.
// header = magic 'OCKL' (4 bytes) + u8 formatVersion + u32le generation
// frame = u8 kind + u32le payloadLength + payload
// kind 0x01 batch — payload u32le seq (one per appended take batch)
// kind 0x02 output — payload utf8 bytes
// kind 0x03 resize — payload u16le cols + u16le rows
// kind 0x04 clear — empty payload
//
// Why framing instead of raw bytes: a crash can tear the final append. Length
// prefixes make the torn tail detectable so restore truncates at the last
// complete frame instead of replaying half an escape sequence ("reading a
// corrupt checkpoint is worse than reading a slightly stale one").
const LOG_MAGIC = 'OCKL'
const LOG_FORMAT_VERSION = 1
export const LOG_HEADER_BYTES = 9
const FRAME_BATCH = 0x01
const FRAME_OUTPUT = 0x02
const FRAME_RESIZE = 0x03
const FRAME_CLEAR = 0x04
export type TerminalHistoryLogBatch = {
seq: number
records: PendingOutputRecord[]
}
export type TerminalHistoryLogContents = {
generation: number
batches: TerminalHistoryLogBatch[]
/** True when the file ended mid-frame (torn final append). The complete
* prefix is still safe to replay. */
truncatedTail: boolean
}
export function encodeLogHeader(generation: number): Buffer {
const header = Buffer.alloc(LOG_HEADER_BYTES)
header.write(LOG_MAGIC, 0, 'ascii')
header.writeUInt8(LOG_FORMAT_VERSION, 4)
header.writeUInt32LE(generation >>> 0, 5)
return header
}
/** Validates magic + format version and returns the generation, or null when
* the buffer is not a readable log header. */
export function decodeLogHeader(buffer: Buffer): number | null {
if (buffer.length < LOG_HEADER_BYTES) {
return null
}
if (buffer.toString('ascii', 0, 4) !== LOG_MAGIC) {
return null
}
if (buffer.readUInt8(4) !== LOG_FORMAT_VERSION) {
return null
}
return buffer.readUInt32LE(5)
}
export function encodeLogBatch(seq: number, records: PendingOutputRecord[]): Buffer {
const frames: Buffer[] = [encodeFrame(FRAME_BATCH, encodeSeqPayload(seq))]
for (const record of records) {
if (record.kind === 'output') {
frames.push(encodeFrame(FRAME_OUTPUT, Buffer.from(record.data, 'utf8')))
} else if (record.kind === 'resize') {
const payload = Buffer.alloc(4)
payload.writeUInt16LE(clampU16(record.cols), 0)
payload.writeUInt16LE(clampU16(record.rows), 2)
frames.push(encodeFrame(FRAME_RESIZE, payload))
} else {
frames.push(encodeFrame(FRAME_CLEAR, Buffer.alloc(0)))
}
}
return Buffer.concat(frames)
}
/** Returns null for missing magic / unknown format version — callers fall
* back to checkpoint-only restore. Seq-gap detection is also done here: a
* non-contiguous batch sequence means an appended batch was lost (e.g. main
* crashed between take and append), so the byte stream has a hole and
* replaying it would corrupt the restored terminal. */
export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogContents | null {
const generation = decodeLogHeader(buffer)
if (generation === null) {
return null
}
const batches: TerminalHistoryLogBatch[] = []
let current: TerminalHistoryLogBatch | null = null
let offset = LOG_HEADER_BYTES
let truncatedTail = false
while (offset < buffer.length) {
if (offset + 5 > buffer.length) {
truncatedTail = true
break
}
const kind = buffer.readUInt8(offset)
const payloadLength = buffer.readUInt32LE(offset + 1)
const payloadStart = offset + 5
const payloadEnd = payloadStart + payloadLength
if (payloadEnd > buffer.length) {
truncatedTail = true
break
}
if (kind === FRAME_BATCH) {
if (payloadLength !== 4) {
return null
}
const seq = buffer.readUInt32LE(payloadStart)
if (current && seq !== current.seq + 1) {
return null
}
current = { seq, records: [] }
batches.push(current)
} else if (!current) {
// A record frame before any batch frame means the writer and format
// disagree — treat the whole log as unreadable.
return null
} else if (kind === FRAME_OUTPUT) {
current.records.push({
kind: 'output',
data: buffer.toString('utf8', payloadStart, payloadEnd)
})
} else if (kind === FRAME_RESIZE) {
if (payloadLength !== 4) {
return null
}
current.records.push({
kind: 'resize',
cols: buffer.readUInt16LE(payloadStart),
rows: buffer.readUInt16LE(payloadStart + 2)
})
} else if (kind === FRAME_CLEAR) {
current.records.push({ kind: 'clear' })
} else {
return null
}
offset = payloadEnd
}
return { generation, batches, truncatedTail }
}
function encodeFrame(kind: number, payload: Buffer): Buffer {
const header = Buffer.alloc(5)
header.writeUInt8(kind, 0)
header.writeUInt32LE(payload.length, 1)
return Buffer.concat([header, payload])
}
function encodeSeqPayload(seq: number): Buffer {
const payload = Buffer.alloc(4)
payload.writeUInt32LE(seq >>> 0, 0)
return payload
}
function clampU16(value: number): number {
return Math.max(0, Math.min(0xffff, Math.floor(value)))
}
+16 -1
View File
@@ -1,7 +1,12 @@
import { Session, type SubprocessHandle } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { resolveProcessCwd } from '../providers/process-cwd'
import type { SessionInfo, TerminalSnapshot, ShellReadyState } from './types'
import type {
SessionInfo,
TakePendingOutputResult,
TerminalSnapshot,
ShellReadyState
} from './types'
import { SessionNotFoundError } from './types'
const DEFAULT_MAX_TOMBSTONES = 1000
@@ -210,6 +215,16 @@ export class TerminalHost {
return session.getSnapshot()
}
// Why: same null-not-throw semantics as getSnapshot — incremental
// checkpoints are best-effort against sessions that may have just exited.
takePendingOutput(sessionId: string, includeSnapshot: boolean): TakePendingOutputResult | null {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
return null
}
return session.takePendingOutput(includeSnapshot)
}
isKilled(sessionId: string): boolean {
return this.killedTombstones.has(sessionId)
}
+58 -2
View File
@@ -3,8 +3,8 @@
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 12
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const
export const PROTOCOL_VERSION = 13
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
@@ -36,6 +36,25 @@ export type TerminalModes = {
alternateScreen: boolean
}
/** On-disk shape of checkpoint.json. Written by history-manager, read by
* history-reader — one type so the generation pairing with output.log's
* header (see terminal-history-log.ts) cannot silently diverge between the
* writer and the consumer. */
export type TerminalCheckpointFile = {
snapshotAnsi: string
scrollbackAnsi: string
rehydrateSequences: string
cwd: string | null
cols: number
rows: number
modes: TerminalModes
scrollbackLines: number
/** Ties this checkpoint to the output.log whose header carries the same
* generation. Absent on checkpoints written before incremental logs. */
generation?: number
checkpointedAt: string
}
// ─── NDJSON Protocol Messages ───────────────────────────────────────
// Hello handshake (first message on each socket)
@@ -195,6 +214,42 @@ export type GetSnapshotRequest = {
}
}
// ─── Incremental checkpoint records (v13+) ──────────────────────────
// Why: the 5s checkpoint used to re-serialize the full emulator buffer per
// tick, stalling the daemon's PTY pump for O(buffer). Incremental checkpoints
// take only the raw records accumulated since the last take; the emulator is
// serialized only when a full snapshot is explicitly requested (clean
// shutdown, pending-buffer overflow, or the on-disk log reaching its cap).
export type PendingOutputRecord =
| { kind: 'output'; data: string }
| { kind: 'resize'; cols: number; rows: number }
| { kind: 'clear' }
export type TakePendingOutputRequest = {
id: string
type: 'takePendingOutput'
payload: {
sessionId: string
/** When true, the daemon serializes a full snapshot in the SAME
* synchronous turn as the take. This atomicity is load-bearing: a
* snapshot taken in a separate request could include bytes that a later
* take would replay again, duplicating content on cold restore. */
includeSnapshot?: boolean
}
}
export type TakePendingOutputResult = {
records: PendingOutputRecord[]
/** Monotonic per-session batch sequence. The history log stores it so the
* cold-restore reader can detect a lost batch (gap) and discard the log
* instead of replaying a stream with missing bytes. */
seq: number
/** True when the session's pending buffer exceeded its cap and records were
* dropped. The caller must fall back to a full snapshot checkpoint. */
overflowed: boolean
snapshot: TerminalSnapshot | null
}
export type DaemonRequest =
| CreateOrAttachRequest
| CancelCreateOrAttachRequest
@@ -212,6 +267,7 @@ export type DaemonRequest =
| SystemResolverHealthRequest
| PtySpawnHealthRequest
| GetSnapshotRequest
| TakePendingOutputRequest
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
+17
View File
@@ -95,6 +95,23 @@ describe('git remote operations', () => {
)
})
it('maps recursive submodule push failures to submodule-specific guidance', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
"Command failed: git push\nPushing submodule 'find-cmux-followers'\n" +
' ! [rejected] master -> master (fetch first)\n' +
"Unable to push submodule 'find-cmux-followers'\n" +
'fatal: failed to push all needed submodules'
)
)
await expect(gitPush('/repo', false)).rejects.toThrow(
"Submodule 'find-cmux-followers' has remote changes. Pull inside the submodule, then try again."
)
})
it('passes through clean tail line when push error does not match known patterns', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
+13 -4
View File
@@ -235,10 +235,19 @@ export async function updateIssue(
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], {
...ghOptions
})
if (updates.state === 'closed') {
const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo]
if (updates.stateReason === 'completed') {
closeArgs.push('--reason', 'completed')
} else if (updates.stateReason === 'not_planned') {
closeArgs.push('--reason', 'not planned')
} else if (updates.stateReason === 'duplicate' && updates.duplicateOf) {
closeArgs.push('--duplicate-of', String(updates.duplicateOf))
}
await ghExecFileAsync(closeArgs, ghOptions)
} else {
await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/open" as a no-op
+23 -3
View File
@@ -171,15 +171,29 @@ export async function updateIssueBySlug(
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
}
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } =
args.updates
const {
title,
body,
state,
stateReason,
duplicateOf,
addLabels,
removeLabels,
addAssignees,
removeAssignees
} = args.updates
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
// Labels/assignees go through their dedicated endpoints.
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
// 1) PATCH body
if (title !== undefined || body !== undefined || state !== undefined) {
if (
title !== undefined ||
body !== undefined ||
state !== undefined ||
stateReason !== undefined
) {
const patchArgs: string[] = ['-X', 'PATCH', base]
if (title !== undefined) {
patchArgs.push('--raw-field', `title=${title}`)
@@ -190,6 +204,12 @@ export async function updateIssueBySlug(
if (state !== undefined) {
patchArgs.push('--raw-field', `state=${state}`)
}
if (stateReason !== undefined) {
patchArgs.push('--raw-field', `state_reason=${stateReason}`)
}
if (duplicateOf !== undefined) {
patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`)
}
const r = await runRest<unknown>(patchArgs)
if (!r.ok) {
return { ok: false, error: r.error }
+18 -85
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
@@ -22,96 +21,12 @@ import {
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
parseGitLabProjectRef,
parseGlabApiResponse,
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('gitlab project ref parsing', () => {
it('parses HTTPS and SSH GitLab.com remotes', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
it('preserves nested group paths', () => {
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
host: 'gitlab.com',
path: 'group/subgroup/project'
})
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
host: 'gitlab.com',
path: 'g1/g2/g3/proj'
})
})
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
})
it('matches self-hosted hosts when included in knownHosts', () => {
expect(
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
expect(
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('rejects single-segment paths (host root or user-only)', () => {
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
})
it('handles missing .git suffix', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('strips trailing slashes after .git suffixes', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('preserves git protocol remote support', () => {
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
})
describe('gitlab project ref resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
@@ -376,6 +291,24 @@ gitlab.example.com:
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com')
})
it('extracts hosts from bare auth-status section headers', () => {
const out = `
gitlab.com
✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml)
✓ Token: **************************
gitlab.internal
✓ Logged in as user2
✓ Token: **************************
Self-hosted-git
✓ Logged in as user3
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
'gitlab.com',
'gitlab.internal',
'self-hosted-git'
])
})
it('returns empty list for output with no hosts', () => {
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
})
+55 -64
View File
@@ -1,9 +1,16 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
import type { ClassifiedError, IssueSourcePreference } from '../../shared/types'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
import {
DEFAULT_GITLAB_HOSTS,
normalizeGitLabHost,
parseGitLabProjectRef,
parseRemoteProjectRefCandidate,
type ProjectRef
} from './project-ref-parser'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
@@ -98,11 +105,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError {
return { type: c.type, message: readMessages[c.type] }
}
// ── Project ref resolution ──────────────────────────────────────────
// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef`
// remains the single source of truth while main-side call sites can use
// the short local name `ProjectRef`.
export type ProjectRef = GitLabProjectRef
export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef }
export type { ProjectRef }
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
const projectRefCache = new Map<string, ProjectRef | null>()
@@ -129,61 +133,6 @@ function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null
}
}
/**
* Hosts always treated as GitLab. Self-hosted instances are added at
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
*/
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
function normalizeHost(value: string): string {
return value.trim().toLowerCase()
}
function stripGitSuffix(path: string): string {
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
}
function makeProjectRef(
host: string,
path: string,
knownHosts: readonly string[]
): ProjectRef | null {
const normalizedHost = normalizeHost(host)
if (!knownHosts.map(normalizeHost).includes(normalizedHost)) {
return null
}
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
// Reject paths without at least one group segment — `gitlab.com:foo`
// alone is not a project reference.
if (!normalizedPath.includes('/')) {
return null
}
return { host: normalizedHost, path: normalizedPath }
}
export function parseGitLabProjectRef(
remoteUrl: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
): ProjectRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
if (scpLike) {
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
}
}
try {
const url = new URL(trimmed)
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(url.hostname, url.pathname, knownHosts)
} catch {
return null
}
}
export async function getProjectRefForRemote(
repoPath: string,
remoteName: string,
@@ -224,6 +173,17 @@ async function resolveProjectRefForRemote(
rememberProjectRefCacheEntry(cacheKey, result)
return result
}
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
if (
remoteCandidate &&
(await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId))
) {
// Why: `glab auth status` is process-global and can be stale or formatted
// differently across versions; the origin host itself is the durable repo context.
rememberGlabKnownHost(remoteCandidate.host)
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
return remoteCandidate
}
} catch {
if (connectionId) {
// Why: remote SSH failures are often transient tunnel/process errors.
@@ -315,6 +275,36 @@ export function glabHostnameArgs(
let knownHostsCache: readonly string[] | null = null
function rememberGlabKnownHost(host: string): void {
const normalizedHost = normalizeGitLabHost(host)
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
return
}
knownHostsCache = [...knownHostsCache, normalizedHost]
}
async function isGlabConfiguredForRemoteHost(
repoPath: string,
projectRef: Pick<ProjectRef, 'host'>,
connectionId?: string | null
): Promise<boolean> {
try {
const result = await glabExecFileAsync(
['auth', 'status', '--hostname', projectRef.host],
glabRepoExecOptions(repoPath, connectionId)
)
return result !== undefined
} catch (error) {
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
const output =
[execLike.stdout, execLike.stderr, execLike.message]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n') || String(error)
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
return hosts.includes(normalizeGitLabHost(projectRef.host))
}
}
/** @internal — exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCache = null
@@ -397,9 +387,10 @@ export function parseGlabAuthStatusHosts(output: string): string[] {
hosts.add(m[1].toLowerCase())
}
for (const line of output.split('\n')) {
const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/)
if (m) {
hosts.add(m[1].toLowerCase())
const bareLine = line.trim()
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
hosts.add(hostLine.toLowerCase())
}
}
return Array.from(hosts)
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser'
describe('gitlab project ref parsing', () => {
it('parses HTTPS and SSH GitLab.com remotes', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
it('preserves nested group paths', () => {
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
host: 'gitlab.com',
path: 'group/subgroup/project'
})
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
host: 'gitlab.com',
path: 'g1/g2/g3/proj'
})
})
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
})
it('matches self-hosted hosts when included in knownHosts', () => {
expect(
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
expect(
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('rejects single-segment paths (host root or user-only)', () => {
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
})
it('handles missing .git suffix', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('strips trailing slashes after .git suffixes', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('preserves git protocol remote support', () => {
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
})
describe('gitlab remote project ref candidates', () => {
it('extracts self-hosted candidates before the host is trusted', () => {
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team/orca.git')).toEqual({
host: 'gitlab.internal',
path: 'team/orca'
})
})
it('rejects non-git URLs and single-segment project paths', () => {
expect(parseRemoteProjectRefCandidate('file:///tmp/repo')).toBeNull()
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team.git')).toBeNull()
})
})
+84
View File
@@ -0,0 +1,84 @@
import type { GitLabProjectRef } from '../../shared/types'
export type ProjectRef = GitLabProjectRef
/**
* Hosts always treated as GitLab. Self-hosted instances are added at
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
*/
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
export function normalizeGitLabHost(value: string): string {
return value.trim().toLowerCase()
}
function stripGitSuffix(path: string): string {
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
}
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
// Reject paths without at least one group segment — `gitlab.com:foo`
// alone is not a project reference.
if (!normalizedPath.includes('/')) {
return null
}
return { host: normalizedHost, path: normalizedPath }
}
function makeProjectRef(
host: string,
path: string,
knownHosts: readonly string[]
): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
if (!normalizedKnownHosts.includes(normalizedHost)) {
return null
}
return makeProjectRefForTrustedHost(normalizedHost, path)
}
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
if (scpLike) {
return makeProjectRefForTrustedHost(scpLike[1], scpLike[2])
}
}
try {
const url = new URL(trimmed)
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
} catch {
return null
}
}
export function parseGitLabProjectRef(
remoteUrl: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
): ProjectRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
if (scpLike) {
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
}
}
try {
const url = new URL(trimmed)
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(url.hostname, url.pathname, knownHosts)
} catch {
return null
}
}
+22 -9
View File
@@ -3,6 +3,10 @@ import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
return args?.distro?.trim() || undefined
}
export function registerCliHandlers(): void {
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
return new CliInstaller().getStatus()
@@ -16,15 +20,24 @@ export function registerCliHandlers(): void {
return new CliInstaller().remove()
})
ipcMain.handle('cli:getWslInstallStatus', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().getStatus()
})
ipcMain.handle(
'cli:getWslInstallStatus',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus()
}
)
ipcMain.handle('cli:installWsl', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().install()
})
ipcMain.handle(
'cli:installWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install()
}
)
ipcMain.handle('cli:removeWsl', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().remove()
})
ipcMain.handle(
'cli:removeWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove()
}
)
}
+13 -3
View File
@@ -1,6 +1,10 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import {
buildWslLoginShellCommand,
escapeWslShCommandForWindows
} from '../../shared/wsl-login-shell-command'
const execFileAsync = promisify(execFile)
const WSL_AGENT_DETECTION_TIMEOUT_MS = 10000
@@ -30,8 +34,8 @@ export async function detectWslCommandsOnPath(
try {
// Why: WSL cold-start plus many parallel wsl.exe probes can timeout and
// cache an empty result. One interactive probe matches user terminals and
// gives the distro a single startup path.
// cache an empty result. One probe through the distro user's login shell
// matches zsh/bash PATH customizations from their normal terminals.
const { stdout } = await execWslAgentDetectionCommand(wslTarget, script)
return parseWslDetectedCommands(stdout)
} catch {
@@ -50,7 +54,13 @@ async function execWslAgentDetectionCommand(
const distroArgs = target.distro ? ['-d', target.distro] : []
const commandPromise = execFileAsync(
'wsl.exe',
[...distroArgs, '--exec', 'bash', '-ic', command],
[
...distroArgs,
'--',
'sh',
'-c',
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
],
{
encoding: 'utf-8',
timeout: WSL_AGENT_DETECTION_TIMEOUT_MS
+33
View File
@@ -0,0 +1,33 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import {
buildWslLoginShellCommand,
escapeWslShCommandForWindows
} from '../../shared/wsl-login-shell-command'
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
const execFileAsync = promisify(execFile)
export type PreflightWslCommandResult = { stdout: string; stderr: string }
export function runPreflightCommandInWsl(
target: WslPreflightTarget,
command: string,
timeoutMs: number
): Promise<PreflightWslCommandResult> {
const distroArgs = target.distro ? ['-d', target.distro] : []
return execFileAsync(
'wsl.exe',
[
...distroArgs,
'--',
'sh',
'-c',
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
],
{
encoding: 'utf-8',
timeout: timeoutMs
}
) as Promise<PreflightWslCommandResult>
}
+18 -10
View File
@@ -278,10 +278,10 @@ describe('preflight', () => {
}
if (command === 'wsl.exe') {
const script = String(args[5])
if (script === "'gh' --version") {
if (script.includes('gh') && script.includes('--version')) {
return { stdout: 'gh version 2.0.0\n' }
}
if (script === "'gh' auth status") {
if (script.includes('gh') && script.includes('auth status')) {
return { stdout: 'github.com\n - Active account: true\n' }
}
throw new Error(`unexpected WSL script ${script}`)
@@ -294,12 +294,12 @@ describe('preflight', () => {
expect(status.gh).toEqual({ installed: true, authenticated: true })
expect(execFileAsyncMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' --version"],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)],
{ encoding: 'utf-8', timeout: 5000 }
)
expect(execFileAsyncMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' auth status"],
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)],
{ encoding: 'utf-8', timeout: 5000 }
)
})
@@ -318,10 +318,18 @@ describe('preflight', () => {
if (command === 'gh' || command === 'glab') {
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
}
if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'gh' --version") {
if (
command === 'wsl.exe' &&
Array.isArray(args) &&
String(args.at(-1)).includes("'gh' --version")
) {
return new Promise(() => {})
}
if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'glab' --version") {
if (
command === 'wsl.exe' &&
Array.isArray(args) &&
String(args.at(-1)).includes("'glab' --version")
) {
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
}
throw new Error(`unexpected command ${String(command)}`)
@@ -606,9 +614,9 @@ describe('preflight', () => {
expect.arrayContaining([
'-d',
'Ubuntu',
'--exec',
'bash',
'-ic',
'--',
'sh',
'-c',
expect.stringContaining("'claude'")
]),
{ encoding: 'utf-8', timeout: 10000 }
@@ -637,7 +645,7 @@ describe('preflight', () => {
expect(resolveCliCommandsMock).not.toHaveBeenCalled()
expect(execFileAsyncMock).toHaveBeenCalledWith(
'wsl.exe',
expect.arrayContaining(['--exec', 'bash', '-ic', expect.stringContaining("'codex'")]),
expect.arrayContaining(['--', 'sh', '-c', expect.stringContaining("'codex'")]),
{ encoding: 'utf-8', timeout: 10000 }
)
})
+2 -5
View File
@@ -11,6 +11,7 @@ import { getGiteaAuthStatus } from '../gitea/client'
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
import { getActiveMultiplexer } from './ssh'
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
const execFileAsync = promisify(execFile)
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
@@ -102,11 +103,7 @@ async function execCommandInWsl(
target: WslPreflightTarget,
command: string
): Promise<{ stdout: string; stderr: string }> {
const distroArgs = target.distro ? ['-d', target.distro] : []
const commandPromise = execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS
}) as Promise<{ stdout: string; stderr: string }>
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
return withPreflightTimeout('wsl.exe', commandPromise)
}
+43 -40
View File
@@ -546,14 +546,14 @@ describe('registerPtyHandlers', () => {
}
}
function spawnAndGetCall(args?: {
async function spawnAndGetCall(args?: {
cwd?: string
env?: Record<string, string>
command?: string
}): [string, string[], { cwd: string; env: Record<string, string> }] {
}): Promise<[string, string[], { cwd: string; env: Record<string, string> }]> {
handlers.clear()
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...args
@@ -3811,11 +3811,11 @@ describe('registerPtyHandlers', () => {
delete process.env.PYTHONUTF8
})
it('passes chcp 65001 to cmd.exe for UTF-8 console output', () => {
it('passes chcp 65001 to cmd.exe for UTF-8 console output', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\system32\\cmd.exe',
@@ -3824,11 +3824,11 @@ describe('registerPtyHandlers', () => {
)
})
it('sets Console encoding for powershell.exe', () => {
it('sets Console encoding for powershell.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
@@ -3837,11 +3837,11 @@ describe('registerPtyHandlers', () => {
)
})
it('sets Console encoding for pwsh.exe', () => {
it('sets Console encoding for pwsh.exe', async () => {
process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
@@ -3850,34 +3850,34 @@ describe('registerPtyHandlers', () => {
)
})
it('sets PYTHONUTF8=1 in the spawn environment on Windows', () => {
it('sets PYTHONUTF8=1 in the spawn environment on Windows', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('1')
})
it('does not override an existing PYTHONUTF8 value', () => {
it('does not override an existing PYTHONUTF8 value', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
process.env.PYTHONUTF8 = '0'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('0')
})
it('launches Git Bash from COMSPEC as an interactive login shell', () => {
it('launches Git Bash from COMSPEC as an interactive login shell', async () => {
process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
@@ -3888,7 +3888,7 @@ describe('registerPtyHandlers', () => {
)
})
it('uses terminalWindowsShell setting over COMSPEC when provided', () => {
it('uses terminalWindowsShell setting over COMSPEC when provided', async () => {
// Why: COMSPEC always points to cmd.exe on stock Windows, so without the
// setting the terminal would ignore the user's shell preference.
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
@@ -3902,7 +3902,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsShell: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3911,7 +3911,7 @@ describe('registerPtyHandlers', () => {
)
})
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', () => {
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
@@ -3924,7 +3924,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3933,7 +3933,7 @@ describe('registerPtyHandlers', () => {
)
})
it('spawns pwsh.exe when PowerShell 7 is selected and available', () => {
it('spawns pwsh.exe when PowerShell 7 is selected and available', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -3947,12 +3947,12 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith('pwsh.exe', POWERSHELL_OSC133_ARGS, expect.any(Object))
})
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', () => {
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -3966,7 +3966,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3975,7 +3975,7 @@ describe('registerPtyHandlers', () => {
)
})
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', () => {
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -3989,7 +3989,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3998,7 +3998,7 @@ describe('registerPtyHandlers', () => {
)
})
it('ignores the PowerShell implementation setting for cmd.exe', () => {
it('ignores the PowerShell implementation setting for cmd.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -4012,7 +4012,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'cmd.exe',
@@ -4021,7 +4021,7 @@ describe('registerPtyHandlers', () => {
)
})
it('ignores the PowerShell implementation setting for wsl.exe', () => {
it('ignores the PowerShell implementation setting for wsl.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -4035,7 +4035,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
@@ -4043,7 +4043,7 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
it('keeps shellOverride priority for one-off tabs', () => {
it('keeps shellOverride priority for one-off tabs', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -4057,7 +4057,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
@@ -4113,7 +4113,7 @@ describe('registerPtyHandlers', () => {
}
})
it('spawns a plain POSIX login shell and queues startup commands for the live session', () => {
it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
@@ -4126,7 +4126,10 @@ describe('registerPtyHandlers', () => {
delete process.env.ZDOTDIR
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp', command: 'printf "hello"' })
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
command: 'printf "hello"'
})
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
@@ -4149,7 +4152,7 @@ describe('registerPtyHandlers', () => {
}
})
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', () => {
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@@ -4160,7 +4163,7 @@ describe('registerPtyHandlers', () => {
process.env.SHELL = '/bin/zsh'
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp' })
const [shell, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
@@ -4180,7 +4183,7 @@ describe('registerPtyHandlers', () => {
}
})
it('uses the POSIX shell wrapper so Pi config survives shell startup files', () => {
it('uses the POSIX shell wrapper so Pi config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@@ -4196,7 +4199,7 @@ describe('registerPtyHandlers', () => {
}))
try {
const [shell, args, options] = spawnAndGetCall({
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }
})
@@ -4233,7 +4236,7 @@ describe('registerPtyHandlers', () => {
process.env.SHELL = '/bin/bash'
try {
spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
await spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
const { getBashShellReadyRcfileContent } = await import('./pty')
const bashRcContent = getBashShellReadyRcfileContent()
@@ -4259,7 +4262,7 @@ describe('registerPtyHandlers', () => {
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
@@ -4288,7 +4291,7 @@ describe('registerPtyHandlers', () => {
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
+56 -3
View File
@@ -102,7 +102,7 @@ vi.mock('./filesystem-auth', () => ({
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
if (id === 'conn-1' || id === 'conn-2') {
return mockGitProvider
}
return undefined
@@ -111,7 +111,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
if (id === 'conn-1' || id === 'conn-2') {
return mockFilesystemProvider
}
return undefined
@@ -120,7 +120,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
if (id === 'conn-1' || id === 'conn-2') {
return mockMultiplexer
}
return undefined
@@ -1298,6 +1298,59 @@ describe('repos:addRemote', () => {
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('allows the same resolved remote path on a different SSH connection', async () => {
const existing = {
id: 'machine-1-project',
path: '/home/user/project',
connectionId: 'conn-1',
displayName: 'project',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-2',
remotePath: '/home/user/project'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/project',
connectionId: 'conn-2'
})
)
expect(result).toHaveProperty('repo.connectionId', 'conn-2')
expect(result).toHaveProperty('repo.id')
expect(result).not.toEqual({ repo: existing })
})
it('dedupes remote projects after git root resolution on the same SSH connection', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/project',
connectionId: 'conn-1',
displayName: 'project',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
isRepo: true,
rootPath: '/home/user/project'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project/src'
})
expect(result).toEqual({ repo: existing })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('throws when SSH connection is not found', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'unknown-conn',
+143 -368
View File
@@ -31,11 +31,6 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
@@ -78,15 +73,11 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath
} from '../project-groups/folder-workspace-path-status'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@@ -121,193 +112,6 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?:
track('repo_added', props)
}
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string,
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: setup-on-host is an explicit user action for this project. When the
// folder lacks upstream metadata but the selected project has provider
// identity, stamp that identity so compatibility projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
repo: project.providerIdentity.repo
}
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
}
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
return buildProjectHostSetupResult(store, repo)
}
async function addLocalRepoFromPath(
store: Store,
path: string,
kind: 'git' | 'folder' = 'git'
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const repoKind = kind === 'folder' ? 'folder' : 'git'
if (repoKind === 'git' && !isGitRepo(path)) {
return { error: `Not a valid git repository: ${path}` }
}
const existing = store.getRepos().find((r) => r.path === path)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path,
displayName: getRepoName(path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
// Why: new Add Project imports should become explicit ready host
// setups; `legacy-repo` is reserved for older records/projection.
projectHostSetupMethod: 'imported-existing-folder' as const
}
: {})
}
store.addRepo(repo)
return { repo, alreadyExisted: false }
}
async function addRemoteRepoFromPath(
store: Store,
args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
setupMethod?: Repo['projectHostSetupMethod']
}
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)
const existing = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const existingAfterRootResolve = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existingAfterRootResolve) {
return { repo: existingAfterRootResolve, alreadyExisted: true }
}
const folderName = getRemoteRepoFolderName(resolvedPath)
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
}
: {})
}
store.addRepo(repo)
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
mux.notify('session.registerRoot', { rootPath: resolvedPath })
}
return { repo, alreadyExisted: false }
}
function getRemoteRepoFolderName(remotePath: string): string {
const trimmed = remotePath.replace(/[\\/]+$/, '')
if (!trimmed) {
@@ -621,18 +425,6 @@ const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>()
const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50
const GIT_AVAILABILITY_TIMEOUT_MS = 1500
function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void {
for (const line of text.split(/[\r\n]+/)) {
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
if (match && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:clone-progress', {
phase: match[1].trim(),
percent: parseInt(match[2], 10)
})
}
}
}
const ProjectGroupCreateArgs = z.object({
name: z.string().min(1),
parentPath: z.string().nullable().optional(),
@@ -661,57 +453,6 @@ const ProjectGroupMoveProjectArgs = z.object({
order: z.number().finite().optional()
})
const ProjectHostSetupExistingFolderIpcArgs = z.object({
projectId: z.string().min(1),
hostId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
})
const ProjectHostSetupCreateIpcArgs = z.object({
projectId: z.string().min(1),
hostId: z
.string()
.min(1)
.transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
setupId: z.string().min(1).optional(),
path: z.string().optional(),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional(),
worktreeBasePath: z.string().optional(),
gitUsername: z.string().optional(),
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
})
const ProjectHostSetupUpdateIpcArgs = z.object({
setupId: z.string().min(1),
updates: z.object({
displayName: z.string().optional(),
path: z.string().optional(),
worktreeBasePath: z.string().optional(),
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z
.enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
.optional(),
gitUsername: z.string().optional(),
kind: z.enum(['git', 'folder']).optional()
})
})
const ProjectHostSetupDeleteIpcArgs = z.object({
setupId: z.string().min(1)
})
const FolderWorkspaceLinkedTaskArgs = z
.object({
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
@@ -1095,7 +836,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
ipcMain.removeHandler('repos:cloneAbort')
ipcMain.removeHandler('repos:cloneRemote')
ipcMain.removeHandler('repos:isGitAvailable')
ipcMain.removeHandler('repos:getDefaultCreateProjectParent')
ipcMain.removeHandler('repos:getGitUsername')
@@ -1113,105 +853,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.getRepos()
})
ipcMain.handle('projects:list', () => store.getProjects())
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle(
'projectHostSetups:create',
(_event, rawArgs: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupCreateIpcArgs,
rawArgs,
'project_host_setup_create_invalid_args'
)
const result = store.createProjectHostSetup(args)
if (!result) {
throw new Error(`Project not found: ${args.projectId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:update',
(_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupUpdateIpcArgs,
rawArgs,
'project_host_setup_update_invalid_args'
)
const result = store.updateProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:delete',
(_event, rawArgs: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupDeleteIpcArgs,
rawArgs,
'project_host_setup_delete_invalid_args'
)
const result = store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:setupExistingFolder',
async (
_event,
rawArgs: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupExistingFolderIpcArgs,
rawArgs,
'project_host_setup_invalid_args'
)
const parsedHost = parseExecutionHostId(args.hostId)
if (!parsedHost) {
throw new Error(`Unsupported host: ${args.hostId}`)
}
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
if (!existingProject) {
throw new Error(`Project not found: ${args.projectId}`)
}
const result =
parsedHost.kind === 'local'
? await addLocalRepoFromPath(store, args.path, args.kind)
: parsedHost.kind === 'ssh'
? await addRemoteRepoFromPath(store, {
connectionId: parsedHost.targetId,
remotePath: args.path,
displayName: args.displayName,
kind: args.kind
})
: {
error:
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
}
if ('error' in result) {
throw new Error(result.error)
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
return alignRepoWithRequestedProject(store, result.repo, args.projectId, args.setupMethod)
}
)
ipcMain.handle('repos:isGitAvailable', () => isGitAvailable())
ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent())
@@ -1537,10 +1178,38 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
// Check if already added
const existing = store.getRepos().find((r) => r.path === args.path)
if (existing) {
emitRepoAdded('folder_picker', true, repoKind === 'git')
return { repo: existing }
}
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path: args.path,
displayName: getRepoName(args.path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
// Why: `repos:add` validates git-ness via `isGitRepo(args.path)` above
// when kind is 'git', and `repoKind` reflects that resolved choice.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
}
)
@@ -1559,6 +1228,113 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
const findExistingRemoteRepo = (path: string): Repo | undefined =>
store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(path)
)
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = args.remotePath
// Why: `~` is a shell expansion that Node's fs APIs don't understand.
// Resolve tilde paths to absolute paths via the relay before storing,
// so all downstream fs operations (readDir, stat, etc.) work correctly.
if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) {
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
try {
const result = (await mux.request('session.resolveHome', {
path: resolvedPath
})) as { resolvedPath: string }
resolvedPath = result.resolvedPath
} catch {
// Relay may not support resolveHome yet — fall through to raw path
}
}
}
// Why: check for duplicates after tilde resolution so that adding `~/`
// when `/home/ubuntu` is already stored correctly detects the duplicate.
const existing = findExistingRemoteRepo(resolvedPath)
if (existing) {
// Why: duplicate hit is suppressed by `emitRepoAdded` anyway, and for
// remote adds git-ness isn't resolved until the isGitRepoAsync check
// below — pass `undefined` rather than guess.
emitRepoAdded('folder_picker', true, undefined)
return { repo: existing }
}
if (args.kind !== 'folder') {
// Why: when kind is not explicitly 'folder', verify the remote path is
// a git repo. Return an error on failure so the renderer can show the "Open as
// Folder" confirmation dialog — matching the local add-repo behavior
// where non-git directories require explicit user consent.
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
const existingAfterRootResolution = findExistingRemoteRepo(resolvedPath)
if (existingAfterRootResolution) {
// Why: users may browse inside a repo; store identity is the Git root,
// but different SSH targets can legitimately share that same path.
emitRepoAdded('folder_picker', true, true)
return { repo: existingAfterRootResolution }
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const folderName = getRemoteRepoFolderName(resolvedPath)
// When folderName is the home directory basename (e.g. 'ubuntu'),
// use SSH target label for a more descriptive name
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
@@ -1575,13 +1351,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
name: string
kind: 'git' | 'folder'
}
): Promise<{ repo: Repo } | { error: string }> => {
const result = await createRemoteRepo(store, args)
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
return result
// Why: `repoKind` here reflects the SSH/remote-aware isGitRepoAsync
// result resolved above (or an explicit 'folder' kind), so it's the real
// git-vs-folder signal for this remote add.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
}
)
+8
View File
@@ -160,6 +160,14 @@ describe('telemetry IPC handlers', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
const handler = handlers.get('telemetry:track')!
handler({}, 'app_starred_orca', { source: 'settings' })
handler({}, 'star_nag_outcome', {
outcome: 'shown',
source: 'threshold',
mode: 'gh',
threshold: 35,
agents_since_baseline: 35,
agents_since_baseline_bucket: '35-69'
})
handler({}, 'feature_interaction_usage_bucket_reached', {
feature_id: 'tasks',
feature_category: 'task_management',
+1
View File
@@ -55,6 +55,7 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
-3
View File
@@ -337,9 +337,6 @@ describe('mergeWorktree', () => {
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
projectId: 'github:stablyai/orca',
hostId: 'ssh:openclaw-2',
projectHostSetupId: 'remote-repo',
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: true,
+2 -1
View File
@@ -60,6 +60,7 @@ type LinearWorkspaceFile = {
export type LinearClientForWorkspace = {
workspace: LinearWorkspace
client: LinearClient
apiKey: string
}
let cachedTokens = new Map<string, string>()
@@ -543,7 +544,7 @@ export function getClients(
if (!token) {
continue
}
clients.push({ workspace, client: new LinearClient({ apiKey: token }) })
clients.push({ workspace, client: new LinearClient({ apiKey: token }), apiKey: token })
}
return clients
}
+18 -1
View File
@@ -58,5 +58,22 @@ export function classifyLinearError(error: unknown): LinearErrorCode {
}
export function linearMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
const message = error instanceof Error ? error.message : String(error)
return sanitizeLinearErrorMessage(message)
}
export function sanitizeLinearErrorMessage(message: string): string {
// Why: provider text is useful in CLI errors, but raw SDK failures can embed secrets or user payloads.
return message
.split(/\r?\n\s+at\s+/)[0]
.replace(
/(headers?\s*[:=]\s*)\{[^{}]*(?:authorization|token|api[-_]?key)[^{}]*\}/gi,
'$1[REDACTED]'
)
.replace(/(authorization\s*[:=]\s*bearer\s+)[^\s]+/gi, '$1[REDACTED]')
.replace(/((?:api[-_]?key|token)\s*[:=]\s*)[^\s,}\]]+/gi, '$1[REDACTED]')
.replace(/(variables\s*[:=]\s*)\{[\s\S]*?\}/gi, '$1[REDACTED]')
.replace(/((?:body|comment|description)\s*[:=]\s*)\{[\s\S]*?\}/gi, '$1[REDACTED]')
.replace(/((?:body|comment|description)\s*[:=]\s*)(["']).*?\2/gi, '$1[REDACTED]')
.trim()
}
+284
View File
@@ -332,4 +332,288 @@ describe('Linear issue queries', () => {
expect(updateIssue).toHaveBeenCalledWith('issue-1', { estimate: 5 })
})
it('reads back agent state updates before confirming success', async () => {
const updateIssue = vi.fn().mockResolvedValue({ success: true })
rawRequest.mockResolvedValueOnce({
data: {
issue: {
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix thing',
description: 'Description',
url: 'https://linear.app/ENG-1',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: { id: 'state-review', name: 'In Review' },
parent: null
}
}
})
getClients.mockReturnValue([
{
...makeEntry(),
client: { updateIssue, client: { rawRequest } }
}
])
const { updateIssueForAgent } = await import('./issues')
await expect(
updateIssueForAgent('issue-1', { stateId: 'state-review' }, 'workspace-1')
).resolves.toMatchObject({ state: { id: 'state-review' } })
expect(updateIssue).toHaveBeenCalledWith('issue-1', { stateId: 'state-review' })
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueByUuid')
})
it('treats post-state-update readback misses as unconfirmed', async () => {
const updateIssue = vi.fn().mockResolvedValue({ success: true })
rawRequest.mockResolvedValueOnce({ data: { issue: null } })
getClients.mockReturnValue([
{
...makeEntry(),
client: { updateIssue, client: { rawRequest } }
}
])
const { updateIssueForAgent } = await import('./issues')
await expect(
updateIssueForAgent('issue-1', { stateId: 'state-review' }, 'workspace-1')
).rejects.toMatchObject({ kind: 'unconfirmed' })
})
it('treats direct write-id lookup misses as null', async () => {
rawRequest
.mockRejectedValueOnce(
new Error('Entity not found: Issue - Could not find referenced Issue.')
)
.mockRejectedValueOnce(
new Error('Entity not found: Comment - Could not find referenced Comment.')
)
.mockRejectedValueOnce(
new Error('Entity not found: Attachment - Could not find referenced Attachment.')
)
getClients.mockReturnValue([{ ...makeEntry(), client: { client: { rawRequest } } }])
const { getIssueByUuidForAgent, getCommentByUuidForAgent, getAttachmentByUuidForAgent } =
await import('./issues')
await expect(getIssueByUuidForAgent('missing-issue', 'workspace-1')).resolves.toBeNull()
await expect(getCommentByUuidForAgent('missing-comment', 'workspace-1')).resolves.toBeNull()
await expect(
getAttachmentByUuidForAgent('missing-attachment', 'workspace-1')
).resolves.toBeNull()
expect(clearToken).not.toHaveBeenCalled()
})
it('sends threaded agent comments with a client supplied id', async () => {
const createComment = vi.fn().mockResolvedValue({
success: true,
comment: Promise.resolve({ id: 'comment-1', url: 'https://linear.app/comment-1' })
})
getClients.mockReturnValue([
{
...makeEntry(),
client: { createComment }
}
])
const { addIssueComment } = await import('./issues')
await expect(
addIssueComment('issue-1', 'hello', 'workspace-1', {
id: '11111111-1111-4111-8111-111111111111',
parentId: 'parent-comment'
})
).resolves.toMatchObject({ ok: true, id: 'comment-1', parentId: 'parent-comment' })
expect(createComment).toHaveBeenCalledWith({
id: '11111111-1111-4111-8111-111111111111',
issueId: 'issue-1',
body: 'hello',
parentId: 'parent-comment'
})
})
it('creates agent attachments with a client supplied id', async () => {
const createAttachment = vi.fn().mockResolvedValue({
success: true,
attachment: Promise.resolve({ id: 'attachment-1' })
})
rawRequest.mockResolvedValueOnce({
data: {
attachment: {
id: 'attachment-1',
title: 'PR link',
url: 'https://example.com/review/1',
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }
}
}
})
getClients.mockReturnValue([
{
...makeEntry(),
client: { createAttachment, client: { rawRequest } }
}
])
const { createIssueAttachment } = await import('./issues')
await expect(
createIssueAttachment(
'issue-1',
{
id: '22222222-2222-4222-8222-222222222222',
title: 'PR link',
url: 'https://example.com/review/1'
},
'workspace-1'
)
).resolves.toMatchObject({ id: 'attachment-1', issue: { identifier: 'ENG-1' } })
expect(createAttachment).toHaveBeenCalledWith({
id: '22222222-2222-4222-8222-222222222222',
issueId: 'issue-1',
title: 'PR link',
url: 'https://example.com/review/1'
})
})
it('treats post-mutation attachment readback misses as unconfirmed', async () => {
const createAttachment = vi.fn().mockResolvedValue({
success: true,
attachment: Promise.resolve({ id: 'attachment-1' })
})
rawRequest.mockResolvedValueOnce({ data: { attachment: null } })
getClients.mockReturnValue([
{
...makeEntry(),
client: { createAttachment, client: { rawRequest } }
}
])
const { createIssueAttachment } = await import('./issues')
await expect(
createIssueAttachment(
'issue-1',
{
id: '22222222-2222-4222-8222-222222222222',
title: 'PR link',
url: 'https://example.com/review/1'
},
'workspace-1'
)
).rejects.toMatchObject({ kind: 'unconfirmed' })
})
it('creates parented agent issues with a client supplied id', async () => {
const createIssue = vi.fn().mockResolvedValue({
success: true,
issue: Promise.resolve({ id: 'issue-created' })
})
rawRequest.mockResolvedValueOnce({
data: {
issue: {
id: 'issue-created',
identifier: 'ENG-2',
title: 'Follow up',
url: 'https://linear.app/ENG-2',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: { id: 'state-1', name: 'Todo' },
parent: { id: 'issue-parent', identifier: 'ENG-1' }
}
}
})
getClients.mockReturnValue([
{
...makeEntry(),
client: { createIssue, client: { rawRequest } }
}
])
const { createIssueForAgent } = await import('./issues')
await expect(
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
id: '33333333-3333-4333-8333-333333333333',
parentId: 'issue-parent'
})
).resolves.toMatchObject({
id: 'issue-created',
parent: { id: 'issue-parent' },
team: { key: 'ENG' }
})
expect(createIssue).toHaveBeenCalledWith({
id: '33333333-3333-4333-8333-333333333333',
teamId: 'team-1',
title: 'Follow up',
description: 'Details',
parentId: 'issue-parent'
})
expect(rawRequest.mock.calls.at(-1)?.[0]).toContain('description')
})
it('treats post-create readback misses as unconfirmed', async () => {
const createIssue = vi.fn().mockResolvedValue({
success: true,
issue: Promise.resolve({ id: 'issue-created' })
})
rawRequest.mockResolvedValueOnce({ data: { issue: null } })
getClients.mockReturnValue([
{
...makeEntry(),
client: { createIssue, client: { rawRequest } }
}
])
const { createIssueForAgent } = await import('./issues')
await expect(
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
id: '33333333-3333-4333-8333-333333333333'
})
).rejects.toMatchObject({ kind: 'unconfirmed' })
})
it('treats post-create readback auth-like errors as unconfirmed', async () => {
const authError = new Error('Auth expired during confirmation')
isAuthError.mockImplementation((error) => error === authError)
const createIssue = vi.fn().mockResolvedValue({
success: true,
issue: Promise.resolve({ id: 'issue-created' })
})
rawRequest.mockRejectedValueOnce(authError)
getClients.mockReturnValue([
{
...makeEntry(),
client: { createIssue, client: { rawRequest } }
}
])
const { createIssueForAgent } = await import('./issues')
await expect(
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
id: '33333333-3333-4333-8333-333333333333'
})
).rejects.toMatchObject({ kind: 'unconfirmed' })
expect(clearToken).not.toHaveBeenCalled()
})
it('resolves a reply target to its thread root without reading capped issue comments', async () => {
rawRequest.mockResolvedValueOnce({
data: {
comment: {
id: 'reply-1',
url: 'https://linear.app/comment/reply-1',
body: 'Nested reply',
parent: { id: 'root-1' },
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }
}
}
})
const { getIssueCommentThreadRoot } = await import('./issues')
await expect(getIssueCommentThreadRoot('issue-1', 'reply-1', 'workspace-1')).resolves.toEqual({
id: 'root-1',
parentId: 'root-1'
})
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearCommentByUuid')
expect(rawRequest.mock.calls[0][0]).toContain('body')
})
})
+541 -4
View File
@@ -8,6 +8,7 @@ import type {
LinearCollectionResult,
LinearWorkspaceSelection
} from '../../shared/types'
import { LinearClient } from '@linear/sdk'
import {
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
clampLinearIssueListLimit
@@ -78,6 +79,47 @@ type LinearIssueConnectionLoader = (
page: LinearIssuePageRequest
) => Promise<LinearIssueConnection | null | undefined>
export type LinearWriteFailureKind = 'duplicate_id' | 'failed' | 'network' | 'unconfirmed'
export class LinearWriteFailure extends Error {
readonly kind: LinearWriteFailureKind
readonly cause: unknown
constructor(kind: LinearWriteFailureKind, message: string, cause?: unknown) {
super(message)
this.name = 'LinearWriteFailure'
this.kind = kind
this.cause = cause
}
}
export type LinearIssueWriteRecord = {
id: string
identifier: string
title: string
description?: string | null
url: string
team: { id: string; key: string; name: string }
state: { id: string; name: string } | null
parent: { id: string; identifier: string } | null
}
export type LinearCommentWriteRecord = {
id: string
url: string | null
body: string
issue: { id: string; identifier: string; url: string }
parentId: string | null
threadRootId: string | null
}
export type LinearAttachmentWriteRecord = {
id: string
title: string
url: string
issue: { id: string; identifier: string; url: string }
}
const LINEAR_ISSUE_NODE_FIELDS = `
id
identifier
@@ -182,6 +224,71 @@ const VIEWER_CREATED_ISSUES_QUERY = `
}
`
const AGENT_ISSUE_WRITE_FIELDS = `
id
identifier
title
description
url
team { id key name }
state { id name }
parent { id identifier }
`
const ISSUE_BY_UUID_QUERY = `
query OrcaLinearIssueByUuid($id: String!) {
issue(id: $id) {
${AGENT_ISSUE_WRITE_FIELDS}
}
}
`
const COMMENT_BY_UUID_QUERY = `
query OrcaLinearCommentByUuid($id: String!) {
comment(id: $id) {
id
url
body
parent { id }
issue { id identifier url }
}
}
`
const ATTACHMENT_BY_UUID_QUERY = `
query OrcaLinearAttachmentByUuid($id: String!) {
attachment(id: $id) {
id
title
url
issue { id identifier url }
}
}
`
type LinearIssueByUuidResponse = {
issue?: LinearIssueWriteRecord | null
}
type LinearCommentByUuidResponse = {
comment?: {
id: string
url?: string | null
body?: string | null
parent?: { id?: string | null } | null
issue?: { id?: string | null; identifier?: string | null; url?: string | null } | null
} | null
}
type LinearAttachmentByUuidResponse = {
attachment?: {
id: string
title?: string | null
url?: string | null
issue?: { id?: string | null; identifier?: string | null; url?: string | null } | null
} | null
}
async function mapIssueForWorkspace(
entry: LinearClientForWorkspace,
issue: Parameters<typeof mapLinearIssue>[0],
@@ -349,6 +456,164 @@ function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undef
return selection !== 'all'
}
function linearWriteMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isDuplicateIdError(error: unknown): boolean {
const message = linearWriteMessage(error).toLowerCase()
return (
message.includes('duplicate') ||
message.includes('already exists') ||
message.includes('already in use') ||
message.includes('id has already')
)
}
function errorCauseCode(error: unknown): string {
if (!error || typeof error !== 'object') {
return ''
}
const cause = (error as { cause?: unknown }).cause
if (!cause || typeof cause !== 'object') {
return ''
}
const code = (cause as { code?: unknown }).code
return typeof code === 'string' ? code.toLowerCase() : ''
}
function classifyWriteFailure(error: unknown): LinearWriteFailure {
if (error instanceof LinearWriteFailure) {
return error
}
if (isDuplicateIdError(error)) {
return new LinearWriteFailure('duplicate_id', linearWriteMessage(error), error)
}
const message = linearWriteMessage(error)
const lower = message.toLowerCase()
const code = errorCauseCode(error)
if (
lower.includes('enotfound') ||
lower.includes('econnrefused') ||
code === 'enotfound' ||
code === 'econnrefused'
) {
return new LinearWriteFailure('network', message, error)
}
if (
lower.includes('abort') ||
lower.includes('timeout') ||
lower.includes('timed out') ||
lower.includes('network') ||
lower.includes('econnreset') ||
lower.includes('fetch failed') ||
lower.includes('socket')
) {
return new LinearWriteFailure('unconfirmed', message, error)
}
return new LinearWriteFailure('failed', message, error)
}
async function runLinearWrite<T>(
entry: LinearClientForWorkspace,
signal: AbortSignal | undefined,
write: (client: LinearClient) => Promise<T>
): Promise<T> {
await acquire()
try {
const client = signal ? new LinearClient({ apiKey: entry.apiKey, signal }) : entry.client
return await write(client)
} catch (error) {
if (error instanceof LinearWriteFailure) {
throw error
}
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
throw classifyWriteFailure(error)
} finally {
release()
}
}
async function runLinearLookup<T>(
entry: LinearClientForWorkspace,
lookup: () => Promise<T>
): Promise<T | null> {
await acquire()
try {
return await lookup()
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
if (isLinearLookupMiss(error)) {
return null
}
throw error
} finally {
release()
}
}
function isLinearLookupMiss(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
// Why: Linear throws for direct entity lookups that miss; write-id probes
// need the same null shape as GraphQL nullable data, not a failed write.
return message.includes('Entity not found:') && message.includes('Could not find referenced')
}
async function confirmLinearWrite<T>(message: string, readback: () => Promise<T>): Promise<T> {
try {
return await readback()
} catch (error) {
throw new LinearWriteFailure('unconfirmed', message, error)
}
}
function mapRawCommentWriteRecord(
comment: NonNullable<LinearCommentByUuidResponse['comment']>
): LinearCommentWriteRecord | null {
const issue = comment.issue
if (!issue?.id || !issue.identifier || !issue.url) {
return null
}
const parentId = comment.parent?.id ?? null
return {
id: comment.id,
url: comment.url ?? null,
body: comment.body ?? '',
issue: {
id: issue.id,
identifier: issue.identifier,
url: issue.url
},
parentId,
threadRootId: parentId ?? comment.id
}
}
function mapRawAttachmentWriteRecord(
attachment: NonNullable<LinearAttachmentByUuidResponse['attachment']>
): LinearAttachmentWriteRecord | null {
const issue = attachment.issue
if (!issue?.id || !issue.identifier || !issue.url || !attachment.url) {
return null
}
return {
id: attachment.id,
title: attachment.title ?? attachment.url,
url: attachment.url,
issue: {
id: issue.id,
identifier: issue.identifier,
url: issue.url
}
}
}
export async function getIssue(
id: string,
workspaceId?: LinearWorkspaceSelection | null
@@ -382,6 +647,74 @@ export async function getIssue(
return null
}
export async function getIssueByUuidForAgent(
id: string,
workspaceId?: string | null
): Promise<LinearIssueWriteRecord | null> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return null
}
return runLinearLookup(entry, async () => {
const result = await entry.client.client.rawRequest<
LinearIssueByUuidResponse,
LinearRawVariables
>(ISSUE_BY_UUID_QUERY, { id })
return result.data?.issue ?? null
})
}
export async function getCommentByUuidForAgent(
id: string,
workspaceId?: string | null
): Promise<LinearCommentWriteRecord | null> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return null
}
return runLinearLookup(entry, async () => {
const result = await entry.client.client.rawRequest<
LinearCommentByUuidResponse,
LinearRawVariables
>(COMMENT_BY_UUID_QUERY, { id })
const comment = result.data?.comment
return comment ? mapRawCommentWriteRecord(comment) : null
})
}
export async function getAttachmentByUuidForAgent(
id: string,
workspaceId?: string | null
): Promise<LinearAttachmentWriteRecord | null> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return null
}
return runLinearLookup(entry, async () => {
const result = await entry.client.client.rawRequest<
LinearAttachmentByUuidResponse,
LinearRawVariables
>(ATTACHMENT_BY_UUID_QUERY, { id })
const attachment = result.data?.attachment
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
})
}
export async function getIssueCommentThreadRoot(
issueId: string,
commentId: string,
workspaceId?: string | null
): Promise<{ id: string; parentId: string | null } | null> {
const comment = await getCommentByUuidForAgent(commentId, workspaceId)
if (!comment || comment.issue.id !== issueId) {
return null
}
return { id: comment.threadRootId ?? comment.id, parentId: comment.parentId }
}
export async function searchIssues(
query: string,
limit = 20,
@@ -622,6 +955,7 @@ export async function createIssue(
description?: string,
workspaceId?: string | null,
options?: {
id?: string
parentId?: string
projectId?: string | null
stateId?: string
@@ -641,6 +975,7 @@ export async function createIssue(
await acquire()
try {
const result = await entry.client.createIssue({
...(options?.id ? { id: options.id } : {}),
teamId,
title,
...(description ? { description } : {}),
@@ -677,6 +1012,61 @@ export async function createIssue(
}
}
export async function createIssueForAgent(
teamId: string,
title: string,
description: string | undefined,
workspaceId: string,
options: {
id: string
parentId?: string | null
signal?: AbortSignal
}
): Promise<LinearIssueWriteRecord> {
const entry = getClients(workspaceId)[0]
if (!entry) {
throw new LinearWriteFailure('failed', 'Not connected to Linear')
}
return runLinearWrite(entry, options.signal, async (client) => {
const result = await client.createIssue({
id: options.id,
teamId,
title,
...(description ? { description } : {}),
...(options.parentId ? { parentId: options.parentId } : {})
})
if (!result.success) {
throw new LinearWriteFailure('failed', 'Linear create failed')
}
const issue = await confirmLinearWrite(
'Issue was created but could not be retrieved',
async () => result.issue
)
if (!issue?.id) {
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
}
return confirmLinearWrite('Issue was created but could not be retrieved', () =>
getCreatedIssueRecord(issue.id, client)
)
})
}
async function getCreatedIssueRecord(
issueId: string,
client: LinearClient
): Promise<LinearIssueWriteRecord> {
const result = await client.client.rawRequest<LinearIssueByUuidResponse, LinearRawVariables>(
ISSUE_BY_UUID_QUERY,
{ id: issueId }
)
const record = result.data?.issue ?? null
if (!record) {
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
}
return record
}
export async function updateIssue(
id: string,
updates: LinearIssueUpdate,
@@ -738,11 +1128,41 @@ export async function updateIssue(
}
}
export async function updateIssueForAgent(
id: string,
updates: Pick<LinearIssueUpdate, 'stateId'>,
workspaceId: string,
options: { signal?: AbortSignal } = {}
): Promise<LinearIssueWriteRecord> {
const entry = getClients(workspaceId)[0]
if (!entry) {
throw new LinearWriteFailure('failed', 'Not connected to Linear')
}
return runLinearWrite(entry, options.signal, async (client) => {
const payload: Record<string, unknown> = {}
if (updates.stateId !== undefined) {
payload.stateId = updates.stateId
}
const result = await client.updateIssue(id, payload)
if (!result.success) {
throw new LinearWriteFailure('failed', 'Linear update failed')
}
return confirmLinearWrite('Issue was updated but could not be retrieved', () =>
getCreatedIssueRecord(id, client)
)
})
}
export async function addIssueComment(
issueId: string,
body: string,
workspaceId?: string | null
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
workspaceId?: string | null,
options?: { id?: string; parentId?: string | null }
): Promise<
| { ok: true; id: string; url?: string | null; parentId?: string | null }
| { ok: false; error: string }
> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return { ok: false, error: 'Not connected to Linear' }
@@ -750,12 +1170,22 @@ export async function addIssueComment(
await acquire()
try {
const result = await entry.client.createComment({ issueId, body })
const result = await entry.client.createComment({
...(options?.id ? { id: options.id } : {}),
issueId,
body,
...(options?.parentId ? { parentId: options.parentId } : {})
})
if (!result.success) {
return { ok: false, error: 'Failed to create comment' }
}
const comment = await result.comment
return { ok: true, id: comment?.id ?? '' }
return {
ok: true,
id: comment?.id ?? '',
url: comment?.url ?? null,
parentId: options?.parentId ?? null
}
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
@@ -768,6 +1198,113 @@ export async function addIssueComment(
}
}
export async function addIssueCommentForAgent(
issueId: string,
body: string,
workspaceId: string,
options: { id: string; parentId?: string | null; signal?: AbortSignal }
): Promise<LinearCommentWriteRecord> {
const entry = getClients(workspaceId)[0]
if (!entry) {
throw new LinearWriteFailure('failed', 'Not connected to Linear')
}
return runLinearWrite(entry, options.signal, async (client) => {
const result = await client.createComment({
id: options.id,
issueId,
body,
...(options.parentId ? { parentId: options.parentId } : {})
})
if (!result.success) {
throw new LinearWriteFailure('failed', 'Failed to create comment')
}
const comment = await confirmLinearWrite(
'Comment was created but could not be retrieved',
async () => result.comment
)
if (!comment?.id) {
throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved')
}
const record = await confirmLinearWrite('Comment was created but could not be retrieved', () =>
readCommentWriteRecord(client, comment.id)
)
if (!record) {
throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved')
}
return record
})
}
export async function createIssueAttachment(
issueId: string,
input: { id: string; title: string; url: string },
workspaceId: string,
options: { signal?: AbortSignal } = {}
): Promise<LinearAttachmentWriteRecord> {
const entry = getClients(workspaceId)[0]
if (!entry) {
throw new LinearWriteFailure('failed', 'Not connected to Linear')
}
return runLinearWrite(entry, options.signal, async (client) => {
const result = await client.createAttachment({
id: input.id,
issueId,
title: input.title,
url: input.url
})
if (!result.success) {
throw new LinearWriteFailure('failed', 'Failed to create attachment')
}
const attachment = await confirmLinearWrite(
'Attachment was created but could not be retrieved',
async () => result.attachment
)
if (!attachment?.id) {
throw new LinearWriteFailure(
'unconfirmed',
'Attachment was created but could not be retrieved'
)
}
const record = await confirmLinearWrite(
'Attachment was created but could not be retrieved',
() => readAttachmentWriteRecord(client, attachment.id)
)
if (!record) {
throw new LinearWriteFailure(
'unconfirmed',
'Attachment was created but could not be retrieved'
)
}
return record
})
}
async function readCommentWriteRecord(
client: LinearClient,
id: string
): Promise<LinearCommentWriteRecord | null> {
const result = await client.client.rawRequest<LinearCommentByUuidResponse, LinearRawVariables>(
COMMENT_BY_UUID_QUERY,
{ id }
)
const comment = result.data?.comment
return comment ? mapRawCommentWriteRecord(comment) : null
}
async function readAttachmentWriteRecord(
client: LinearClient,
id: string
): Promise<LinearAttachmentWriteRecord | null> {
const result = await client.client.rawRequest<LinearAttachmentByUuidResponse, LinearRawVariables>(
ATTACHMENT_BY_UUID_QUERY,
{ id }
)
const attachment = result.data?.attachment
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
}
export async function getIssueComments(
issueId: string,
workspaceId?: string | null
+58
View File
@@ -67,6 +67,32 @@ export async function listTeams(
return results.flat().sort((a, b) => a.name.localeCompare(b.name))
}
export async function listTeamsOrThrow(
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearTeam[]> {
const entries = getClients(workspaceId)
if (entries.length === 0) {
return []
}
const results = await Promise.all(
entries.map(async (entry) => {
await acquire()
try {
return await fetchAllTeamsForWorkspace(entry)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
})
)
return results.flat().sort((a, b) => a.name.localeCompare(b.name))
}
export async function getTeamStates(
teamId: string,
workspaceId?: string | null
@@ -101,6 +127,38 @@ export async function getTeamStates(
}
}
export async function getTeamStatesOrThrow(
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return []
}
await acquire()
try {
const team = await entry.client.team(teamId)
const states = await team.states()
return states.nodes
.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
color: s.color,
position: s.position
}))
.sort((a, b) => a.position - b.position)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
}
export async function getTeamLabels(
teamId: string,
workspaceId?: string | null
+20 -81
View File
@@ -314,87 +314,6 @@ describe('Store', () => {
expect(store.getRepos()).toEqual([])
}, 15_000)
it('backfills project host setup compatibility records from legacy repos on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({
id: 'local-repo',
path: '/Users/alice/orca',
displayName: 'Orca',
upstream: { owner: 'StablyAI', repo: 'Orca' }
}),
makeRepo({
id: 'remote-repo',
path: '/home/alice/orca',
displayName: 'orca',
connectionId: 'gpu-vm',
upstream: { owner: 'stablyai', repo: 'orca' }
})
]
})
const store = await createStore()
expect(store.getProjects()).toEqual([
expect.objectContaining({
id: 'github:stablyai/orca',
sourceRepoIds: ['local-repo', 'remote-repo']
})
])
expect(store.getProjectHostSetups()).toEqual([
expect.objectContaining({
id: 'local-repo',
projectId: 'github:stablyai/orca',
hostId: 'local',
path: '/Users/alice/orca'
}),
expect.objectContaining({
id: 'remote-repo',
projectId: 'github:stablyai/orca',
hostId: 'ssh:gpu-vm',
path: '/home/alice/orca'
})
])
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.projects).toEqual(store.getProjects())
expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups())
})
it('preserves independent project host setup records on load', async () => {
const independentProject = makeProject({
id: 'cloud-project',
displayName: 'Cloud Project'
})
const independentSetup = makeProjectHostSetup({
id: 'cloud-project::gpu-vm',
projectId: independentProject.id,
hostId: 'runtime:gpu-vm',
repoId: '',
path: '/srv/cloud-project',
displayName: 'GPU VM'
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })],
projects: [independentProject],
projectHostSetups: [independentSetup]
})
const store = await createStore()
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project'])
expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([
'r1',
'cloud-project::gpu-vm'
])
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.projectHostSetups).toContainEqual(independentSetup)
})
it('returns default settings when no data file exists', async () => {
const store = await createStore()
const settings = store.getSettings()
@@ -779,6 +698,26 @@ describe('Store', () => {
expect(store.getUI().projectOrderBy).toBe('manual')
})
it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => {
writeDataFile({
schemaVersion: 1,
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
ui: {}
})
const store = await createStore()
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false)
})
it('hides the manual-default notice for profiles that already chose recent', async () => {
writeDataFile({
schemaVersion: 1,
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
ui: { projectOrderBy: 'recent' }
})
const store = await createStore()
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true)
})
// ── 2. Load from existing valid file ─────────────────────────────────
it('reads repos from an existing data file', async () => {
+73 -3
View File
@@ -114,6 +114,11 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
import {
normalizeLeftSidebarAppearanceMode,
normalizeLeftSidebarTintColor,
normalizeLeftSidebarTintOpacity
} from '../shared/left-sidebar-appearance'
import {
compareFeatureInteractionUsageBuckets,
getFeatureInteractionCategory,
@@ -479,6 +484,11 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][
return getDefaultUIState().projectOrderBy
}
import {
isExistingPersistedProfile,
resolveProjectOrderManualDefaultNoticeDismissed
} from '../shared/project-order-manual-default-notice'
function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
if (
tab === 'explorer' ||
@@ -2361,6 +2371,14 @@ export class Store {
parsed.settings?.disabledTuiAgents
)
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
parsed.settings ?? {},
'openLinksInApp'
)
const migratedOpenLinksInAppPreferencePrompted =
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
? parsed.settings.openLinksInAppPreferencePrompted
: openLinksInAppWasPersisted
if (
parsed.settings?.agentYoloDefaultsMigrated !== true ||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
@@ -2377,6 +2395,12 @@ export class Store {
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
if (
parsed.settings?.openLinksInAppPreferencePrompted !==
migratedOpenLinksInAppPreferencePrompted
) {
this.loadNeedsSave = true
}
const normalizedOnboarding = normalizeLoadedOnboardingState(
parsed.onboarding,
defaults.onboarding
@@ -2440,6 +2464,15 @@ export class Store {
terminalCustomThemes: normalizeTerminalCustomThemes(
parsed.settings?.terminalCustomThemes
),
leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode(
parsed.settings?.leftSidebarAppearanceMode
),
leftSidebarTintColor: normalizeLeftSidebarTintColor(
parsed.settings?.leftSidebarTintColor
),
leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity(
parsed.settings?.leftSidebarTintOpacity
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
@@ -2454,6 +2487,7 @@ export class Store {
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
// Why: new builds read sourceControlAi, but rollback builds still
@@ -2594,12 +2628,28 @@ export class Store {
parsed.ui?.setupGuideSidebarDismissed,
normalizedOnboarding
)
const projectOrderManualDefaultNoticeDismissed =
resolveProjectOrderManualDefaultNoticeDismissed({
rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed,
rawProjectOrderBy: parsed.ui?.projectOrderBy,
isExistingProfile: isExistingPersistedProfile({
repoCount: parsed.repos?.length ?? 0,
onboardingClosedAt: normalizedOnboarding.closedAt,
ui: parsed.ui
})
})
if (
parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed &&
(setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined)
) {
this.loadNeedsSave = true
}
if (
parsed.ui?.projectOrderManualDefaultNoticeDismissed !==
projectOrderManualDefaultNoticeDismissed
) {
this.loadNeedsSave = true
}
return {
...defaults.ui,
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
@@ -2608,6 +2658,7 @@ export class Store {
rightSidebarOpen,
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
setupGuideSidebarDismissed,
projectOrderManualDefaultNoticeDismissed,
setupGuideBrowserMilestoneMigrated:
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
? parsed.ui.setupGuideBrowserMilestoneMigrated
@@ -2716,11 +2767,15 @@ export class Store {
this.loadNeedsSave = true
}
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
workspaceSession: migratedScrollback.session
})
if (folderScopeConnectionMigration.changed) {
this.loadNeedsSave = true
}
result = folderScopeConnectionMigration.state
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
@@ -3955,6 +4010,21 @@ export class Store {
updates.terminalCustomThemes
)
}
if ('leftSidebarAppearanceMode' in updates) {
sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode(
updates.leftSidebarAppearanceMode
)
}
if ('leftSidebarTintColor' in updates) {
sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor(
updates.leftSidebarTintColor
)
}
if ('leftSidebarTintOpacity' in updates) {
sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity(
updates.leftSidebarTintOpacity
)
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
@@ -0,0 +1,94 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
const execFileAsync = promisify(execFile)
type ProcessRow = {
pid: number
ppid: number
stat: string
command: string
}
function parsePsRows(stdout: string): ProcessRow[] {
const rows: ProcessRow[] = []
for (const line of stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
stat: match[3],
command: match[4]
})
}
return rows
}
function collectDescendants(
rows: ProcessRow[],
rootPid: number
): (ProcessRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessRow[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (ProcessRow & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
function candidateScore(row: ProcessRow & { depth: number }): number {
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
// parent shell but still lets the native child confirm the same identity.
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null
): Promise<string | null> {
if (process.platform === 'win32' || !shellPid) {
return fallbackProcess
}
try {
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,stat=,command='], {
encoding: 'utf8',
timeout: 3000
})
return resolveAgentForegroundProcessFromPs(stdout, shellPid) ?? fallbackProcess
} catch {
// Fall through to node-pty's process name. Foreground process inspection is
// best-effort because terminal identity should never break PTY operation.
}
return fallbackProcess
}
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
const candidates = collectDescendants(parsePsRows(stdout), shellPid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
for (const candidate of candidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
return recognized.processName
}
}
return null
}
+4 -10
View File
@@ -345,10 +345,11 @@ describe('LocalPtyProvider', () => {
'-d',
'Debian',
'--',
'bash',
'sh',
'-c',
'cd \'/mnt/c/Users/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
])
expect(spawnCall[1][5]).toContain('exec "\\$_orca_wsl_shell" -l')
})
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
@@ -517,14 +518,7 @@ describe('LocalPtyProvider', () => {
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) })
)
})
+2 -1
View File
@@ -39,6 +39,7 @@ import {
resolveWindowsGitBashShellPath
} from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from './agent-foreground-process'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
@@ -710,7 +711,7 @@ export class LocalPtyProvider implements IPtyProvider {
return null
}
try {
return proc.process || null
return await resolveAgentForegroundProcess(proc.pid, proc.process || null)
} catch {
return null
}
-1
View File
@@ -21,7 +21,6 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
describeMaxBufferOverflowError,
isMaxBufferOverflowError
+19 -29
View File
@@ -3,8 +3,18 @@ import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
} from '../powershell-osc133-bootstrap'
import {
buildWslInteractiveLoginShellCommand,
escapeWslShCommandForWindows
} from '../../shared/wsl-login-shell-command'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}`
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)]
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}
describe('resolveWindowsShellLaunchArgs', () => {
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
@@ -95,12 +105,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
'C:\\Users\\alice\\code',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual([
'--',
'bash',
'-c',
'cd \'/mnt/c/Users/alice/code\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
// Why: WSL cannot cd into a Windows path, so node-pty must start from the
// user's Windows home and we inject the Linux cd into the shellArgs above.
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
@@ -109,17 +114,16 @@ describe('resolveWindowsShellLaunchArgs', () => {
it('escapes single quotes when translating a WSL cwd', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
// The injected bash cmd must not break out of the surrounding single
// quotes when the path contains a ' character.
expect(result.shellArgs[3]).toBe(
"cd '/mnt/c/weird'\\''path' && export PATH=\"$HOME/.local/bin:$PATH\" && exec bash -l"
)
// The injected sh cmd must not break out of the surrounding single quotes
// when the path contains a ' character.
expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'")
expect(result.shellArgs[3]).toContain('exec "\\$_orca_wsl_shell" -l')
})
it('falls back to /mnt/c when cwd is not a drive-letter path', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice')
expect(result.shellArgs[3]).toBe(
'cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
expect(result.shellArgs[3]).toContain(
'cd \'/mnt/c\' && export PATH="\\$HOME/.local/bin:\\$PATH"'
)
})
@@ -136,14 +140,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual([
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/alice/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo')
} finally {
@@ -162,14 +159,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
)
expect(result.shellArgs).toEqual([
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/alice/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
})
+15 -11
View File
@@ -1,6 +1,11 @@
import { win32 as pathWin32 } from 'path'
import { isWindowsGitBashShellPath } from '../git-bash'
import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl'
import {
buildWslInteractiveLoginShellCommand,
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
@@ -32,15 +37,14 @@ export type WindowsShellWslContext = {
}
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
// Why: Orca's WSL bridge is installed under ~/.local/bin, but distro login
// files do not consistently include that directory before agent commands run.
const shellArgs = [
'--',
'bash',
'-c',
`cd '${escapedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
]
const setupCommand = [
`cd ${quotePosixShell(linuxCwd)}`,
'export PATH="$HOME/.local/bin:$PATH"',
buildWslInteractiveLoginShellCommand()
].join(' && ')
// Why: WSL users often customize zsh rather than bash; launch the distro's
// login shell so terminal PATH matches the environment Orca detects.
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(setupCommand)]
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}
@@ -50,8 +54,8 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
* - powershell.exe / pwsh.exe: dot-source $PROFILE and force UTF-8 I/O so
* oh-my-posh / starship / PSReadLine keep working. `-NoExit` alone would
* skip the profile.
* - wsl.exe: translate the Windows cwd to /mnt/<drive>/... and enter a login
* bash inside the default distro.
* - wsl.exe: translate the Windows cwd to /mnt/<drive>/... and enter the
* distro user's login shell.
* - anything else: no args, same cwd. */
export function resolveWindowsShellLaunchArgs(
shellPath: string,
+145
View File
@@ -9444,6 +9444,151 @@ describe('OrcaRuntimeService', () => {
expect(spawn.mock.calls[1]?.[0]).not.toHaveProperty('sessionId')
})
it('keeps the activated headless tab active across PTY republishes (serve focus-jump regression)', async () => {
// Why: in `orca serve`, focusTerminal has no renderer to persist the remote
// client's tab choice before PTY republishes.
let nextPty = 0
const spawn = vi.fn().mockImplementation(async () => ({ id: `headless-pty-${++nextPty}` }))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const FIRST_LEAF = '22222222-2222-4222-8222-222222222222'
const SECOND_LEAF = '33333333-3333-4333-8333-333333333333'
// The first-created headless terminal is the one the snapshot marks active.
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'tab-first',
leafId: FIRST_LEAF
})
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'tab-other',
leafId: SECOND_LEAF
})
const events: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
// The remote client switches to the other (non-active) tab.
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-other')
const afterActivate = events.at(-1)
expect(afterActivate?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
expect(afterActivate?.activeTabType).toBe('terminal')
expect(afterActivate?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
expect(
afterActivate?.tabs.find((tab) => tab.id === `tab-other::${SECOND_LEAF}`)?.isActive
).toBe(true)
expect(afterActivate?.tabs.find((tab) => tab.id === `tab-first::${FIRST_LEAF}`)?.isActive).toBe(
false
)
// PTY title updates republish snapshots, so the client's chosen tab must
// survive after activation.
events.length = 0
runtime.onPtyData('headless-pty-2', '\x1b]0;tab-other running\x07', 200)
const afterPtyData = events.at(-1)
expect(afterPtyData?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
expect(afterPtyData?.activeTabType).toBe('terminal')
expect(afterPtyData?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
})
it('does not bump the snapshot version when re-activating the already-active headless tab', async () => {
// Why: redundant activations of the current tab must not force a remote re-render.
const spawn = vi.fn().mockResolvedValue({ id: 'headless-pty-solo' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const LEAF = '44444444-4444-4444-8444-444444444444'
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-solo', leafId: LEAF })
const events: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-solo')
expect(events).toHaveLength(0)
})
it('does not persist active server-side when an authoritative renderer is attached', async () => {
// Why: when a renderer window is authoritative it re-syncs the snapshot itself,
// so the headless persist must NOT fire — the renderer stays the source of truth.
let nextPty = 0
const spawn = vi.fn().mockImplementation(async () => ({ id: `attached-pty-${++nextPty}` }))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const LEAF_A = '55555555-5555-4555-8555-555555555555'
const LEAF_B = '66666666-6666-4666-8666-666666666666'
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
// Make an authoritative renderer window present.
runtime.attachWindow(1)
runtime.markGraphReady(1)
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send: vi.fn() }
})
const events: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
// The headless persist is gated off by the authoritative window — nothing emitted.
expect(events).toHaveLength(0)
})
it('does not persist active server-side for a `:headless-merge:` snapshot after renderer detach', async () => {
// Why: after renderer detach, merged snapshots have no authoritative window
// but still carry renderer-owned group state.
let nextPty = 0
const spawn = vi.fn().mockImplementation(async () => ({ id: `merge-pty-${++nextPty}` }))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const LEAF_A = '77777777-7777-4777-8777-777777777777'
const LEAF_B = '88888888-8888-4888-8888-888888888888'
// tab-a (first-created) is the snapshot's active tab.
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
// Simulate a post-detach merged snapshot with no authoritative window.
const current = runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)!
runtime['mobileSessionTabsByWorktree'].set(TEST_WORKTREE_ID, {
...current,
publicationEpoch: `renderer:headless-merge:${current.publicationEpoch}`
})
const events: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
// The merge exclusion must suppress server-side active rewrites.
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
expect(events).toHaveLength(0)
})
it('spawns fresh SSH terminals when hydrated persistence has no relay identity', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
File diff suppressed because it is too large Load Diff
@@ -153,6 +153,7 @@ const UiUpdate = z
groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
showWorkspaceLineage: z.boolean().optional(),
sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
projectOrderBy: z.enum(['manual', 'recent']).optional(),
showActiveOnly: z.boolean().optional(),
hideSleepingWorkspaces: z.boolean().optional(),
showSleepingWorkspaces: z.boolean().optional(),
@@ -208,6 +209,7 @@ const UiUpdate = z
starNagCompleted: z.boolean().optional(),
trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
setupScriptPromptDismissedRepoIds: StringArray.optional(),
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
usageEmptyStateDismissed: z.boolean().optional(),
petVisible: z.boolean().optional(),
petId: z.string().optional(),
@@ -0,0 +1,446 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import { OrcaRuntimeService } from '../../orca-runtime'
import { LinearWriteFailure } from '../../../linear/issues'
import { sanitizeLinearErrorMessage } from '../../../linear/issue-context-errors'
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('Linear agent access RPC methods', () => {
it('routes agent write methods to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueSetState: vi.fn().mockResolvedValue({ ok: true }),
linearIssueAddComment: vi.fn().mockResolvedValue({ ok: true }),
linearIssueAttachLink: vi.fn().mockResolvedValue({ ok: true }),
linearIssueCreate: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const setStateResponse = await dispatcher.dispatch(
makeRequest('linear.issueSetState', {
input: 'ENG-1',
to: 'In Review',
workspaceId: 'workspace-1'
})
)
const commentResponse = await dispatcher.dispatch(
makeRequest('linear.issueAddComment', {
input: 'ENG-1',
body: 'Done',
replyTo: 'comment-1',
writeId: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1'
})
)
const attachResponse = await dispatcher.dispatch(
makeRequest('linear.issueAttachLink', {
input: 'ENG-1',
url: 'https://example.com/review/1',
title: 'Review',
writeId: '22222222-2222-4222-8222-222222222222',
workspaceId: 'workspace-1'
})
)
const createResponse = await dispatcher.dispatch(
makeRequest('linear.issueCreate', {
title: 'Follow up',
body: 'Details',
teamKey: 'ENG',
parentInput: 'ENG-1',
writeId: '33333333-3333-4333-8333-333333333333',
workspaceId: 'workspace-1'
})
)
expect(setStateResponse.ok).toBe(true)
expect(commentResponse.ok).toBe(true)
expect(attachResponse.ok).toBe(true)
expect(createResponse.ok).toBe(true)
expect(runtime.linearIssueSetState).toHaveBeenCalledWith({
input: 'ENG-1',
to: 'In Review',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueAddComment).toHaveBeenCalledWith({
input: 'ENG-1',
body: 'Done',
replyTo: 'comment-1',
writeId: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith({
input: 'ENG-1',
url: 'https://example.com/review/1',
title: 'Review',
writeId: '22222222-2222-4222-8222-222222222222',
workspaceId: 'workspace-1'
})
expect(runtime.linearIssueCreate).toHaveBeenCalledWith({
title: 'Follow up',
body: 'Details',
teamKey: 'ENG',
parentInput: 'ENG-1',
writeId: '33333333-3333-4333-8333-333333333333',
workspaceId: 'workspace-1'
})
})
it('rejects malformed write ids before the runtime is called', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueAddComment: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.issueAddComment', {
input: 'ENG-1',
body: 'Done',
writeId: 'not-a-uuid'
})
)
expect(response.ok).toBe(false)
expect(response.ok === false ? response.error.code : '').toBe('linear_invalid_write_id')
expect(runtime.linearIssueAddComment).not.toHaveBeenCalled()
})
it('rejects workspace all for direct write RPC calls', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueSetState: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.issueSetState', {
input: 'ENG-1',
to: 'In Review',
workspaceId: 'all'
})
)
expect(response.ok).toBe(false)
expect(response.ok === false ? response.error.message : '').toContain(
'--workspace all is not valid for Linear writes'
)
expect(runtime.linearIssueSetState).not.toHaveBeenCalled()
})
})
type LinearWriteRunner = {
runLinearAgentWrite<T>(
write: (signal: AbortSignal) => Promise<T>,
unconfirmed: (cause?: string) => Error
): Promise<T>
}
type LinearUnconfirmedBuilder = {
linearCreateStyleUnconfirmed(
verb: 'comment' | 'attach' | 'create',
writeId: string,
target: unknown,
extra?: unknown
): Error & { data?: { cause?: string; nextSteps?: string[] } }
resolveLinearAgentState(input: string, states: unknown[]): unknown | null
notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string): Promise<void>
listResolvedWorktrees(): Promise<unknown[]>
}
type LinearRetryLookupTester = {
getMatchingLinearCommentWrite(
writeId: string,
issueId: string,
parentId: string | null,
workspaceId: string,
required: boolean
): Promise<unknown | null>
getMatchingLinearAttachmentWrite(
writeId: string,
issueId: string,
workspaceId: string,
required: boolean
): Promise<unknown | null>
getMatchingLinearCreatedIssue(
writeId: string,
teamId: string,
parentId: string | null,
workspaceId: string,
required: boolean
): Promise<unknown | null>
refetchLinearCommentAfterDuplicate(
writeId: string,
issueId: string,
parentId: string | null,
workspaceId: string,
unconfirmed: () => Error
): Promise<unknown>
readLinearWriteLookup(lookup: () => Promise<unknown>): Promise<unknown>
}
describe('Linear agent write recovery helpers', () => {
it('keeps stable write failure codes while preserving the Linear provider message', async () => {
const runtime = new OrcaRuntimeService()
const runner = runtime as unknown as LinearWriteRunner
await expect(
runner.runLinearAgentWrite(
async () => {
throw new LinearWriteFailure(
'failed',
'Linear rejected the state transition because the issue is archived.'
)
},
() => Object.assign(new Error('should not be used'), { code: 'linear_write_unconfirmed' })
)
).rejects.toMatchObject({
code: 'linear_write_failed',
message: 'Linear rejected the state transition because the issue is archived.'
})
})
it('keeps pinned retry guidance for unconfirmed writes while adding sanitized cause text', async () => {
const runtime = new OrcaRuntimeService()
const runner = runtime as unknown as LinearWriteRunner
const builder = runtime as unknown as LinearUnconfirmedBuilder
const writeId = '11111111-1111-4111-8111-111111111111'
const target = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
await expect(
runner.runLinearAgentWrite(
async () => {
throw new LinearWriteFailure(
'unconfirmed',
'Linear write could not be confirmed.',
new Error('fetch failed: socket hang up Authorization: Bearer linear-secret-token')
)
},
(cause) =>
builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
bodyRequired: true,
cause
})
)
).rejects.toMatchObject({
code: 'linear_write_unconfirmed',
data: {
cause: 'fetch failed: socket hang up Authorization: Bearer [REDACTED]',
nextSteps: [expect.stringContaining(`--write-id=${writeId}`)]
}
})
})
it('sanitizes Linear provider messages before they enter RPC error envelopes', () => {
const message = sanitizeLinearErrorMessage(
'Linear rejected mutation variables: {"body":"user comment payload","id":"issue-1"} headers: {Authorization: Bearer token-123}\n at handler (linear.ts:1:1)'
)
expect(message).toContain('Linear rejected mutation')
expect(message).toContain('variables: [REDACTED]')
expect(message).toContain('headers: [REDACTED]')
expect(message).not.toContain('user comment payload')
expect(message).not.toContain('token-123')
expect(message).not.toContain('at handler')
})
it('returns unconfirmed at the write deadline even when the request ignores abort', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService()
const write = vi.fn((_signal: AbortSignal) => new Promise<string>(() => undefined))
const unconfirmed = vi.fn(() =>
Object.assign(new Error('unconfirmed'), { code: 'linear_write_unconfirmed' })
)
const pending = (runtime as unknown as LinearWriteRunner).runLinearAgentWrite(
write,
unconfirmed
)
const rejection = expect(pending).rejects.toMatchObject({
code: 'linear_write_unconfirmed'
})
await vi.advanceTimersByTimeAsync(25_000)
await rejection
expect(unconfirmed).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0]?.[0].aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('keeps payload and destination details in pinned retries', () => {
const runtime = new OrcaRuntimeService()
const builder = runtime as unknown as LinearUnconfirmedBuilder
const writeId = '11111111-1111-4111-8111-111111111111'
const target = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
const parent = {
workspaceId: 'workspace-1',
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
}
const comment = builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
parentId: 'comment-root',
bodyRequired: true
})
const attach = builder.linearCreateStyleUnconfirmed('attach', writeId, target, {
title: 'Review link',
url: 'https://example.invalid/review/1'
})
const create = builder.linearCreateStyleUnconfirmed('create', writeId, null, {
parent,
team: { id: 'team-2', key: 'OTHER', name: 'Other', workspaceId: 'workspace-1' },
title: 'Follow up',
bodyRequired: true
})
expect(comment.data?.nextSteps?.[0]).toContain('--body-file -')
expect(comment.data?.nextSteps?.[0]).toContain('--reply-to=comment-root')
expect(attach.data?.nextSteps?.[0]).toContain('--url URL_HERE')
expect(attach.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
expect(attach.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE/URL_HERE')
expect(create.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
expect(create.data?.nextSteps?.[0]).toContain('--body-file -')
expect(create.data?.nextSteps?.[0]).toContain('--parent=ENG-123')
expect(create.data?.nextSteps?.[0]).toContain('--team=OTHER')
expect(create.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE')
})
it('resolves workflow states by UUID or case-insensitive exact name', () => {
const runtime = new OrcaRuntimeService()
const states = [
{ id: 'state-review', name: 'In Review', type: 'started' },
{ id: 'state-done', name: 'Done', type: 'completed' }
]
const builder = runtime as unknown as LinearUnconfirmedBuilder
expect(builder.resolveLinearAgentState('In Review', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('in review', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('STATE-REVIEW', states)).toBe(states[0])
expect(builder.resolveLinearAgentState('Review', states)).toBeNull()
})
it('deduplicates write-id lookups by relationship target without comparing payloads', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearRetryLookupTester
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'comment-1',
body: 'different retry body',
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' },
parentId: 'comment-root',
threadRootId: 'comment-root',
url: null
}))
await expect(
tester.getMatchingLinearCommentWrite(
'11111111-1111-4111-8111-111111111111',
'issue-1',
'comment-root',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'comment-1' })
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'attachment-1',
title: 'Different title',
url: 'https://example.invalid/different',
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' }
}))
await expect(
tester.getMatchingLinearAttachmentWrite(
'22222222-2222-4222-8222-222222222222',
'issue-1',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'attachment-1' })
tester.readLinearWriteLookup = vi.fn(async () => ({
id: 'issue-2',
identifier: 'ENG-2',
title: 'Different title',
description: 'Different body',
url: 'https://example.invalid/ENG-2',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: null,
parent: { id: 'issue-1', identifier: 'ENG-1' }
}))
await expect(
tester.getMatchingLinearCreatedIssue(
'33333333-3333-4333-8333-333333333333',
'team-1',
'issue-1',
'workspace-1',
true
)
).resolves.toMatchObject({ id: 'issue-2' })
})
it('keeps the unconfirmed retry envelope when duplicate recovery lookup fails', async () => {
const runtime = new OrcaRuntimeService()
const tester = runtime as unknown as LinearRetryLookupTester
const unconfirmed = Object.assign(new Error('try pinned retry again'), {
code: 'linear_write_unconfirmed',
data: { writeId: '11111111-1111-4111-8111-111111111111' }
})
tester.readLinearWriteLookup = vi.fn(async () => {
throw Object.assign(new Error('socket reset during lookup'), {
code: 'linear_network_error'
})
})
await expect(
tester.refetchLinearCommentAfterDuplicate(
'11111111-1111-4111-8111-111111111111',
'issue-1',
null,
'workspace-1',
() => unconfirmed
)
).rejects.toBe(unconfirmed)
})
it('emits linked issue refresh events for matching workspace links', async () => {
const runtime = new OrcaRuntimeService()
const builder = runtime as unknown as LinearUnconfirmedBuilder
const events: unknown[] = []
runtime.onClientEvent((event) => events.push(event))
builder.listResolvedWorktrees = vi.fn(async () => [
{
id: 'worktree-1',
linkedLinearIssue: 'eng-123',
linkedLinearIssueWorkspaceId: 'workspace-1'
},
{
id: 'worktree-2',
linkedLinearIssue: 'ENG-123',
linkedLinearIssueWorkspaceId: 'workspace-2'
}
])
await builder.notifyLinearLinkedIssueUpdated('workspace-1', 'ENG-123')
expect(events).toEqual([
{
type: 'linearLinkedIssueUpdated',
worktreeId: 'worktree-1',
identifier: 'ENG-123',
workspaceId: 'workspace-1'
}
])
})
})
@@ -1,6 +1,9 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { linearError } from '../../../linear/issue-context-errors'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const AgentSearchIssues = z.object({
query: requiredString('Missing query'),
@@ -24,6 +27,15 @@ const LinearCurrentContext = z
})
.optional()
const LinearWriteTarget = z.object({
input: OptionalString,
current: z.boolean().optional(),
workspaceId: OptionalString.refine((value) => value !== 'all', {
message: '--workspace all is not valid for Linear writes'
}),
context: LinearCurrentContext
})
const AgentIssueContext = z.object({
input: OptionalString,
current: z.boolean().optional(),
@@ -33,6 +45,45 @@ const AgentIssueContext = z.object({
context: LinearCurrentContext
})
const LinearIssueSetState = LinearWriteTarget.extend({
to: requiredString('Missing target state')
})
const LinearIssueAddComment = LinearWriteTarget.extend({
body: requiredString('Missing comment body'),
replyTo: OptionalString,
writeId: OptionalString
})
const LinearIssueAttachLink = LinearWriteTarget.extend({
url: requiredString('Missing attachment URL'),
title: OptionalString,
writeId: OptionalString
})
const LinearIssueCreate = z.object({
title: requiredString('Missing issue title'),
body: OptionalString,
teamKey: OptionalString,
parentInput: OptionalString,
parentCurrent: z.boolean().optional(),
workspaceId: OptionalString.refine((value) => value !== 'all', {
message: '--workspace all is not valid for Linear writes'
}),
writeId: OptionalString,
context: LinearCurrentContext
})
function parseLinearWriteId(writeId: string | undefined): string | undefined {
if (writeId === undefined) {
return undefined
}
if (!UUID_PATTERN.test(writeId)) {
throw linearError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
defineMethod({
name: 'linear.agentSearchIssues',
@@ -53,5 +104,28 @@ export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
name: 'linear.resolveCurrentIssue',
params: LinearCurrentContext,
handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params)
}),
defineMethod({
name: 'linear.issueSetState',
params: LinearIssueSetState,
handler: async (params, { runtime }) => runtime.linearIssueSetState(params)
}),
defineMethod({
name: 'linear.issueAddComment',
params: LinearIssueAddComment,
handler: async (params, { runtime }) =>
runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) })
}),
defineMethod({
name: 'linear.issueAttachLink',
params: LinearIssueAttachLink,
handler: async (params, { runtime }) =>
runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) })
}),
defineMethod({
name: 'linear.issueCreate',
params: LinearIssueCreate,
handler: async (params, { runtime }) =>
runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) })
})
]
-228
View File
@@ -399,234 +399,6 @@ describe('repo RPC methods', () => {
})
})
it('routes project and project-host setup lists to the runtime server', async () => {
const project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
}
const setup = {
id: 'repo-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/repo',
displayName: 'Project',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
const runtime = {
getRuntimeId: () => 'test-runtime',
listProjects: vi.fn().mockReturnValue([project]),
listProjectHostSetups: vi.fn().mockReturnValue([setup])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const projectsResponse = await dispatcher.dispatch(makeRequest('project.list'))
const setupsResponse = await dispatcher.dispatch(makeRequest('projectHostSetup.list'))
expect(projectsResponse).toMatchObject({ ok: true, result: { projects: [project] } })
expect(setupsResponse).toMatchObject({ ok: true, result: { setups: [setup] } })
expect(runtime.listProjects).toHaveBeenCalled()
expect(runtime.listProjectHostSetups).toHaveBeenCalled()
})
it('routes project-host setup import to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'repo-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/repo',
displayName: 'Project',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
repo: { id: 'repo-1', path: '/repo', displayName: 'Project' }
}
const runtime = {
getRuntimeId: () => 'test-runtime',
setupProjectExistingFolder: vi.fn().mockResolvedValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.setupExistingFolder', {
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git',
setupMethod: 'cloned'
})
)
expect(runtime.setupProjectExistingFolder).toHaveBeenCalledWith({
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git',
setupMethod: 'cloned'
})
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('routes project-host setup creates to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-1',
projectId: 'project-1',
hostId: 'runtime:env-1',
repoId: '',
path: '',
displayName: 'GPU VM',
setupState: 'setting-up',
setupMethod: 'provisioned',
createdAt: 1,
updatedAt: 2
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
createProjectHostSetup: vi.fn().mockReturnValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.create', {
projectId: 'project-1',
hostId: 'runtime:env-1',
setupId: 'setup-1',
displayName: 'GPU VM',
setupState: 'setting-up',
setupMethod: 'provisioned'
})
)
expect(runtime.createProjectHostSetup).toHaveBeenCalledWith({
projectId: 'project-1',
hostId: 'runtime:env-1',
setupId: 'setup-1',
displayName: 'GPU VM',
setupState: 'setting-up',
setupMethod: 'provisioned'
})
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('routes project-host setup updates to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-1',
projectId: 'project-1',
hostId: 'runtime:env-1',
repoId: '',
path: '/srv/project',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
updateProjectHostSetup: vi.fn().mockReturnValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.update', {
setupId: 'setup-1',
updates: {
displayName: 'GPU VM',
path: '/srv/project',
setupState: 'ready'
}
})
)
expect(runtime.updateProjectHostSetup).toHaveBeenCalledWith({
setupId: 'setup-1',
updates: {
displayName: 'GPU VM',
path: '/srv/project',
setupState: 'ready'
}
})
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('routes project-host setup deletes to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-1',
projectId: 'project-1',
hostId: 'runtime:env-1',
repoId: '',
path: '/srv/project',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteProjectHostSetup: vi.fn().mockReturnValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.delete', {
setupId: 'setup-1'
})
)
expect(runtime.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('allows separate nested-repo imports without a group name', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
-1
View File
@@ -4,7 +4,6 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
const RepoSelector = z.object({
+4
View File
@@ -140,7 +140,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'browser.screencast.unsubscribe',
'browser.tabCreate',
'browser.viewport',
'clipboard.abortImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.commitImageUpload',
'clipboard.saveImageAsTempFile',
'clipboard.startImageUpload',
'diagnostics.memory',
'files.browseServerDir',
'files.createFile',
@@ -0,0 +1,193 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
gitExecFileAsyncMock,
glabExecFileAsyncMock,
ghExecFileAsyncMock,
getAzureDevOpsRepoSlugMock,
getBitbucketRepoSlugMock,
getGiteaRepoSlugMock,
getHostedReviewForBranchMock,
getRepoSlugMock,
getSshGitProviderMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getAzureDevOpsRepoSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getGiteaRepoSlugMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
glabExecFileAsync: glabExecFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
extractExecError: vi.fn()
}))
vi.mock('../github/client', () => ({
createGitHubPullRequest: vi.fn(),
getRepoSlug: getRepoSlugMock,
getPRForBranch: vi.fn()
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
getBitbucketPullRequestForBranch: vi.fn(),
getBitbucketPullRequest: vi.fn()
}))
vi.mock('../azure-devops/client', () => ({
getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock,
getAzureDevOpsPullRequestForBranch: vi.fn(),
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('./hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
function resetMocks(): void {
for (const mock of [
gitExecFileAsyncMock,
glabExecFileAsyncMock,
ghExecFileAsyncMock,
getAzureDevOpsRepoSlugMock,
getBitbucketRepoSlugMock,
getGiteaRepoSlugMock,
getHostedReviewForBranchMock,
getRepoSlugMock,
getSshGitProviderMock
]) {
mock.mockReset()
}
_resetKnownHostsCache()
_resetProjectRefCache()
}
function mockNonGitLabProviders(): void {
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue(null)
}
describe('GitLab self-hosted hosted review creation eligibility', () => {
beforeEach(() => {
resetMocks()
mockNonGitLabProviders()
getHostedReviewForBranchMock.mockResolvedValue(null)
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'git@gitlab.internal:team/orca.git\n',
stderr: ''
})
})
it('enables MR creation when glab recognizes the self-hosted origin host', async () => {
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
return {
stdout: `gitlab.internal
✓ Logged in as user
`,
stderr: ''
}
}
if (args[0] === 'auth' && args[1] === 'status') {
return {
stdout: `gitlab.com
✓ Logged in to gitlab.com as user
`,
stderr: ''
}
}
return { stdout: '', stderr: '' }
})
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/self-hosted-mr',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitlab',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/self-hosted-mr'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['auth', 'status', '--hostname', 'gitlab.internal'],
{ cwd: '/repo' }
)
})
it('classifies known-but-unauthenticated self-hosted GitLab as auth_required', async () => {
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
const error = new Error('invalid token provided') as Error & {
stdout: string
stderr: string
}
error.stdout = `gitlab.internal
! Invalid token provided
`
error.stderr = ''
throw error
}
if (args[0] === 'auth' && args[1] === 'status') {
return {
stdout: `gitlab.com
✓ Logged in to gitlab.com as user
gitlab.internal
! Invalid token provided
`,
stderr: ''
}
}
return { stdout: '', stderr: '' }
})
const result = await getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/self-hosted-mr',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
expect(result).toMatchObject({
provider: 'gitlab',
canCreate: false,
blockedReason: 'auth_required',
nextAction: 'authenticate'
})
})
})
@@ -5,24 +5,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
const { httpsGetMock } = vi.hoisted(() => ({
httpsGetMock: vi.fn()
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
describe('ModelManager download failures', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
})
it('rejects failed model downloads so the caller can surface the error', async () => {
@@ -31,8 +29,15 @@ describe('ModelManager download failures', () => {
const manifest = SPEECH_MODEL_CATALOG[0]
const errorHandlers: ((err: Error) => void)[] = []
const request = {
destroy: vi.fn(() => request),
setTimeout: vi.fn(() => request),
abort: vi.fn(() => request),
end: vi.fn(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(new Error('network down'))
}
})
return request
}),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
@@ -49,14 +54,7 @@ describe('ModelManager download failures', () => {
return request
})
}
httpsGetMock.mockImplementation(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(new Error('network down'))
}
})
return request
})
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir)
await expect(manager.downloadModel(manifest.id)).rejects.toThrow('network down')
@@ -5,21 +5,19 @@ import { PassThrough } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelManager } from './model-manager'
const { httpsGetMock } = vi.hoisted(() => ({
httpsGetMock: vi.fn()
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
type ModelManagerInternals = {
downloadFile: (
url: string,
@@ -33,7 +31,7 @@ type ModelManagerInternals = {
describe('ModelManager stream cleanup', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
})
it('removes response progress listeners after a model download finishes', async () => {
@@ -45,16 +43,24 @@ describe('ModelManager stream cleanup', () => {
}
response.statusCode = 200
response.headers = { 'content-length': '4' }
const responseHandlers: ((response: unknown) => void)[] = []
const request = {
destroy: vi.fn(() => request),
setTimeout: vi.fn(() => request),
on: vi.fn(() => request),
abort: vi.fn(() => request),
end: vi.fn(() => {
for (const handler of responseHandlers) {
handler(response)
}
return request
}),
on: vi.fn((event: string, cb: (response: unknown) => void) => {
if (event === 'response') {
responseHandlers.push(cb)
}
return request
}),
off: vi.fn(() => request)
}
httpsGetMock.mockImplementation((_url: URL, cb: (response: unknown) => void) => {
cb(response)
return request
})
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
+159 -64
View File
@@ -6,15 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
const { hasOpenAiSpeechApiKeyMock, httpsGetMock, spawnMock } = vi.hoisted(() => ({
const { hasOpenAiSpeechApiKeyMock, netRequestMock, spawnMock } = vi.hoisted(() => ({
hasOpenAiSpeechApiKeyMock: vi.fn(),
httpsGetMock: vi.fn(),
netRequestMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
@@ -23,11 +26,6 @@ vi.mock('child_process', async () => {
return { ...(actual as Record<string, unknown>), spawn: spawnMock }
})
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
vi.mock('./openai-api-key-store', () => ({
hasOpenAiSpeechApiKey: hasOpenAiSpeechApiKeyMock
}))
@@ -52,7 +50,7 @@ type ModelManagerInternals = {
describe('ModelManager', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
hasOpenAiSpeechApiKeyMock.mockReset()
hasOpenAiSpeechApiKeyMock.mockReturnValue(false)
spawnMock.mockReset()
@@ -129,23 +127,30 @@ describe('ModelManager', () => {
try {
const manifest = SPEECH_MODEL_CATALOG[0]
const errorHandlers: ((err: Error) => void)[] = []
const timeoutHandlers: (() => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
destroy: vi.fn((err?: Error) => {
abort: vi.fn(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(err ?? new Error('destroyed'))
handler(new Error('Aborted'))
}
})
return request
}),
setTimeout: vi.fn((_ms: number, cb: () => void) => {
timeoutHandlers.push(cb)
return request
}),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
@@ -156,75 +161,154 @@ describe('ModelManager', () => {
errorHandlers.splice(index, 1)
}
}
if (event === 'timeout') {
const index = timeoutHandlers.indexOf(cb as () => void)
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
timeoutHandlers.splice(index, 1)
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
})
}),
end: vi.fn(() => request)
}
httpsGetMock.mockImplementation(
(
_url: URL,
options: { signal?: AbortSignal } | ((response: unknown) => void),
_cb?: (response: unknown) => void
) => {
if (typeof options !== 'function') {
options.signal?.addEventListener('abort', () => request.destroy(new Error('Aborted')), {
once: true
})
}
return request
}
)
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir)
const download = manager.downloadModel(manifest.id)
manager.cancelDownload(manifest.id)
await expect(download).resolves.toBeUndefined()
expect(request.destroy).toHaveBeenCalledWith(expect.any(Error))
expect(netRequestMock).toHaveBeenCalledWith({
method: 'GET',
url: expect.stringMatching(/^https:\/\//)
})
expect(request.end).toHaveBeenCalled()
expect(request.abort).toHaveBeenCalled()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(timeoutHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
expect((await manager.getModelState(manifest.id)).status).toBe('not-downloaded')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('settles immediately when the abort signal fires before a response', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const errorHandlers: ((err: Error) => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
abort: vi.fn(() => request),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
off: vi.fn((event: string, cb: ((err: Error) => void) | (() => void)) => {
if (event === 'error') {
const index = errorHandlers.indexOf(cb as (err: Error) => void)
if (index !== -1) {
errorHandlers.splice(index, 1)
}
}
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
}),
end: vi.fn(() => request)
}
netRequestMock.mockReturnValue(request)
const controller = new AbortController()
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
1,
'm',
() => true,
controller.signal
)
const outcomePromise = download.then(
() => 'resolved',
(error) => (error instanceof Error ? error.message : String(error))
)
controller.abort()
await vi.advanceTimersByTimeAsync(0)
await expect(outcomePromise).resolves.toBe('Aborted')
expect(request.abort).toHaveBeenCalled()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
}
})
it('times out a model download request that never responds', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const errorHandlers: ((err: Error) => void)[] = []
const timeoutHandlers: (() => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
destroy: vi.fn((err?: Error) => {
if (err) {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(err)
}
})
}
return request
}),
setTimeout: vi.fn((ms: number, cb: () => void) => {
timeoutHandlers.push(cb)
setTimeout(() => {
for (const handler of timeoutHandlers) {
handler()
}
}, ms)
return request
}),
abort: vi.fn(() => request),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
@@ -235,16 +319,25 @@ describe('ModelManager', () => {
errorHandlers.splice(index, 1)
}
}
if (event === 'timeout') {
const index = timeoutHandlers.indexOf(cb as () => void)
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
timeoutHandlers.splice(index, 1)
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
})
}),
end: vi.fn(() => request)
}
httpsGetMock.mockReturnValue(request)
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
@@ -263,11 +356,13 @@ describe('ModelManager', () => {
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('Model download timed out after 120 seconds without network activity')
expect(request.destroy).toHaveBeenCalledWith()
expect(request.abort).toHaveBeenCalledWith()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(timeoutHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
+83 -61
View File
@@ -1,11 +1,9 @@
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
import { app } from 'electron'
import { app, net } from 'electron'
import { join, resolve, relative } from 'path'
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs'
import { readdir, rm } from 'fs/promises'
import { createHash } from 'crypto'
import { get as httpsGet } from 'https'
import type { IncomingMessage } from 'http'
import { pipeline } from 'stream/promises'
import { spawn } from 'child_process'
import type {
@@ -22,6 +20,12 @@ type DownloadHandle = {
}
type ProgressCallback = (modelId: string, progress: number) => void
type DownloadIncomingMessage = Electron.IncomingMessage &
NodeJS.ReadableStream & {
headers: Record<string, string | string[] | undefined>
resume: () => void
destroy?: () => void
}
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
@@ -287,16 +291,35 @@ export class ModelManager {
}
let settled = false
let request: ReturnType<typeof httpsGet> | null = null
let request: Electron.ClientRequest | null = null
let idleTimeout: ReturnType<typeof setTimeout> | null = null
const onSignalAbort = (): void => {
const activeRequest = request
rejectOnce(new Error('Aborted'))
activeRequest?.abort()
}
const clearIdleTimeout = (): void => {
if (idleTimeout) {
clearTimeout(idleTimeout)
idleTimeout = null
}
}
const cleanupRequestListeners = (): void => {
const activeRequest = request
clearIdleTimeout()
if (!activeRequest) {
return
}
activeRequest.off('error', onRequestError)
activeRequest.off('timeout', onRequestTimeout)
activeRequest.off('response', onResponse)
activeRequest.off('redirect', onRedirect)
signal?.removeEventListener('abort', onSignalAbort)
request = null
}
const resetIdleTimeout = (): void => {
clearIdleTimeout()
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
}
const resolveOnce = (): void => {
if (settled) {
return
@@ -321,62 +344,57 @@ export class ModelManager {
`Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity`
)
)
activeRequest?.destroy()
activeRequest?.abort()
}
const onResponse = (response: IncomingMessage): void => {
if (
response.statusCode === 301 ||
response.statusCode === 302 ||
response.statusCode === 303 ||
response.statusCode === 307 ||
response.statusCode === 308
) {
const redirectUrl = response.headers.location
if (!redirectUrl) {
response.resume()
rejectOnce(new Error('Redirect without location'))
return
}
if (redirectCount >= 5) {
response.resume()
rejectOnce(new Error('Too many redirects'))
return
}
let resolvedRedirect: URL
try {
resolvedRedirect = new URL(redirectUrl, parsedUrl)
} catch {
response.resume()
rejectOnce(new Error('Invalid redirect URL'))
return
}
if (resolvedRedirect.protocol !== 'https:') {
response.resume()
rejectOnce(new Error('Model download redirect must use HTTPS'))
return
}
response.resume()
this.downloadFile(
resolvedRedirect.toString(),
dest,
expectedSize,
modelId,
isAborted,
signal,
redirectCount + 1
)
.then(resolveOnce)
.catch(rejectOnce)
const onRedirect = (_statusCode: number, _method: string, redirectUrl: string): void => {
if (redirectCount >= 5) {
const activeRequest = request
rejectOnce(new Error('Too many redirects'))
activeRequest?.abort()
return
}
let resolvedRedirect: URL
try {
resolvedRedirect = new URL(redirectUrl, parsedUrl)
} catch {
const activeRequest = request
rejectOnce(new Error('Invalid redirect URL'))
activeRequest?.abort()
return
}
if (resolvedRedirect.protocol !== 'https:') {
const activeRequest = request
rejectOnce(new Error('Model download redirect must use HTTPS'))
activeRequest?.abort()
return
}
const activeRequest = request
cleanupRequestListeners()
activeRequest?.abort()
this.downloadFile(
resolvedRedirect.toString(),
dest,
expectedSize,
modelId,
isAborted,
signal,
redirectCount + 1
)
.then(resolveOnce)
.catch(rejectOnce)
}
const onResponse = (incoming: Electron.IncomingMessage): void => {
const response = incoming as DownloadIncomingMessage
if (response.statusCode !== 200) {
response.resume()
rejectOnce(new Error(`HTTP ${response.statusCode}`))
return
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10) || expectedSize
const contentLength = response.headers['content-length']
const totalSize =
parseInt(Array.isArray(contentLength) ? contentLength[0] : contentLength || '0', 10) ||
expectedSize
let downloaded = 0
const fileStream = createWriteStream(dest)
@@ -385,9 +403,10 @@ export class ModelManager {
response.off('data', onResponseData)
}
const onResponseData = (chunk: Buffer): void => {
resetIdleTimeout()
if (isAborted()) {
request?.destroy(new Error('Aborted'))
response.destroy()
request?.abort()
response.destroy?.()
fileStream.destroy()
return
}
@@ -412,15 +431,18 @@ export class ModelManager {
})
}
request = signal
? httpsGet(parsedUrl, { signal }, onResponse)
: httpsGet(parsedUrl, onResponse)
request = net.request({ method: 'GET', url: parsedUrl.toString() })
// Why: cancellation only helps after the user presses cancel; a peer
// that accepts the socket and goes silent must not leave the model stuck
// in "downloading" forever.
request.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, onRequestTimeout)
// Why: Electron's net stack honors app proxy settings, unlike Node's
// https client, but it does not expose request.setTimeout().
resetIdleTimeout()
request.on('error', onRequestError)
request.on('response', onResponse)
request.on('redirect', onRedirect)
if (signal) {
signal.addEventListener('abort', onSignalAbort, { once: true })
}
request.end()
})
}
+7 -1
View File
@@ -655,7 +655,13 @@ export class SshRelaySession {
)
)
: {}
return await runRemoteOrcaCli(this.runtime, { argv, cwd, env })
const stdin = typeof params.stdin === 'string' ? params.stdin : undefined
return await runRemoteOrcaCli(this.runtime, {
argv,
cwd,
env,
...(stdin !== undefined ? { stdin } : {})
})
})
}
+97 -2
View File
@@ -1,10 +1,17 @@
import type { LinearIssueContextResult, LinearSearchResult } from '../../shared/linear-agent-access'
import type {
LinearAttachResult,
LinearCommentAddResult,
LinearCreateResult,
LinearIssueContextResult,
LinearSearchResult,
LinearStatusSetResult
} from '../../shared/linear-agent-access'
import type { CliStatusResult } from '../../shared/runtime-types'
import type { RpcResponse } from '../runtime/rpc/core'
export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } {
if (!response.ok) {
return { stdout: '', stderr: `${response.error.message}\n` }
return { stdout: '', stderr: `${formatRemoteCliError(response.error)}\n` }
}
const result = response.result
if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) {
@@ -23,9 +30,32 @@ export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr
stderr: linearSearchWarnings(result)
}
}
if (isLinearStatusSetResult(result)) {
return { stdout: `${formatLinearStatusSet(result)}\n`, stderr: '' }
}
if (isLinearCommentAddResult(result)) {
return { stdout: `${formatLinearCommentAdd(result)}\n`, stderr: '' }
}
if (isLinearAttachResult(result)) {
return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' }
}
if (isLinearCreateResult(result)) {
return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' }
}
return { stdout: `${JSON.stringify(result)}\n`, stderr: '' }
}
function formatRemoteCliError(error: { message: string; data?: unknown }): string {
const nextSteps =
isRecord(error.data) && Array.isArray(error.data.nextSteps)
? error.data.nextSteps.filter((step): step is string => typeof step === 'string')
: []
if (nextSteps.length === 0) {
return error.message
}
return `${error.message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
}
function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } {
return {
stdout: `${[
@@ -70,6 +100,50 @@ function isLinearSearchResult(result: unknown): result is LinearSearchResult {
)
}
function isLinearStatusSetResult(result: unknown): result is LinearStatusSetResult {
return (
isRecord(result) &&
isRecord(result.issue) &&
isRecord(result.state) &&
isRecord(result.meta) &&
typeof result.state.name === 'string' &&
typeof result.meta.alreadyInState === 'boolean'
)
}
function isLinearCommentAddResult(result: unknown): result is LinearCommentAddResult {
return (
isRecord(result) &&
isRecord(result.comment) &&
isRecord(result.issue) &&
isRecord(result.meta) &&
typeof result.comment.id === 'string' &&
typeof result.meta.bodyChars === 'number'
)
}
function isLinearAttachResult(result: unknown): result is LinearAttachResult {
return (
isRecord(result) &&
isRecord(result.attachment) &&
isRecord(result.issue) &&
isRecord(result.meta) &&
typeof result.attachment.title === 'string' &&
typeof result.attachment.url === 'string'
)
}
function isLinearCreateResult(result: unknown): result is LinearCreateResult {
return (
isRecord(result) &&
isRecord(result.issue) &&
isRecord(result.meta) &&
typeof result.issue.identifier === 'string' &&
typeof result.issue.title === 'string' &&
typeof result.meta.writeId === 'string'
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
@@ -112,6 +186,27 @@ function formatLinearSearch(result: LinearSearchResult): string {
.join('\n')
}
function formatLinearStatusSet(result: LinearStatusSetResult): string {
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
}
function formatLinearCommentAdd(result: LinearCommentAddResult): string {
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
}
function formatLinearAttach(result: LinearAttachResult): string {
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
}
function formatLinearCreate(result: LinearCreateResult): string {
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
const suffix = result.meta.deduplicated ? ' (already created)' : ''
return `Created ${result.issue.identifier}${parent}: ${result.issue.title}${suffix}.`
}
function linearIssueWarnings(result: LinearIssueContextResult): string {
const warnings: string[] = []
for (const error of result.meta.includeErrors) {
+186
View File
@@ -50,6 +50,63 @@ function createRuntime() {
partial: false,
workspaceErrors: []
}
})),
linearIssueSetState: vi.fn(async (request: unknown) => ({
request,
issue: {
id: 'issue-1',
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 }
})),
linearIssueAddComment: vi.fn(async (request: unknown) => ({
request,
comment: { id: 'comment-1', url: null, parentId: null },
issue: {
id: 'issue-1',
identifier: 'ENG-123',
url: 'https://linear.app/acme/issue/ENG-123'
},
meta: {
workspaceId: 'workspace-1',
bodyChars: 4,
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
})),
linearIssueAttachLink: vi.fn(async (request: unknown) => ({
request,
attachment: { id: 'attachment-1', title: 'PR/MR link', url: 'https://example.com/review/1' },
issue: {
id: 'issue-1',
identifier: 'ENG-123',
url: 'https://linear.app/acme/issue/ENG-123'
},
meta: {
workspaceId: 'workspace-1',
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
})),
linearIssueCreate: vi.fn(async (request: unknown) => ({
request,
issue: {
id: 'issue-2',
identifier: 'ENG-456',
title: 'Follow-up',
url: 'https://linear.app/acme/issue/ENG-456',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: { id: 'state-triage', name: 'Triage' },
parent: { id: 'issue-1', identifier: 'ENG-123' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}))
} as unknown as OrcaRuntimeService
return runtime
@@ -129,6 +186,110 @@ describe('runRemoteOrcaCli Linear commands', () => {
})
})
it('dispatches Linear status writes through the remote runtime with SSH context hints', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'],
cwd: '/home/alice/remote-repo',
env: {
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_WORKTREE_ID: 'repo::remote'
}
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { request: { input: string; to: string; context: Record<string, unknown> } }
}
expect(payload.ok).toBe(true)
expect(payload.result.request).toMatchObject({
input: 'ENG-123',
to: 'In Review',
context: {
remote: true,
terminalHandle: 'term_ssh',
worktreeId: 'repo::remote'
}
})
})
it('formats SSH Linear writes in non-json mode', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('Added comment comment-1 to ENG-123.\n')
expect(result.stderr).toBe('')
})
it('dispatches body-file stdin writes in the SSH shim', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' },
stdin: 'line one\nline two\n'
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { request: { body: string } }
}
expect(payload.ok).toBe(true)
expect(payload.result.request.body).toBe('line one\nline two\n')
})
it('rejects body-file stdin writes when SSH stdin is unavailable', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.error).toMatchObject({
code: 'invalid_argument',
message: 'SSH Linear writes require stdin when using --body-file -.'
})
})
it('rejects remote body-file paths in the SSH shim before dispatch', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'comment', 'add', '--current', '--body-file', 'body.md', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.error).toMatchObject({
code: 'invalid_argument',
message: 'SSH Linear writes only support --body-file - for stdin.'
})
})
it('formats SSH Linear issue reads in non-json mode', async () => {
const runtime = createRuntime()
@@ -223,6 +384,30 @@ describe('runRemoteOrcaCli Linear commands', () => {
expect(result.stderr).toContain('Linear is not connected.')
})
it('prints SSH Linear non-json next steps from structured errors', async () => {
const runtime = createRuntime()
const linearIssueAddComment = (
runtime as unknown as { linearIssueAddComment: ReturnType<typeof vi.fn> }
).linearIssueAddComment
linearIssueAddComment.mockRejectedValueOnce(
Object.assign(new Error('Linear may have applied the write.'), {
code: 'linear_write_unconfirmed',
data: { nextSteps: ['Retry once with the pinned command: `orca linear comment add`.'] }
})
)
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('Linear may have applied the write.')
expect(result.stderr).toContain('Next step: Retry once with the pinned command')
})
it('shows SSH Linear command help without dispatching to the runtime', async () => {
const runtime = createRuntime()
const linearIssueContext = (
@@ -257,6 +442,7 @@ describe('runRemoteOrcaCli Linear commands', () => {
expect(result.stdout).toContain('orca linear')
expect(result.stdout).toContain('Usage: orca linear <command> [options]')
expect(result.stdout).toContain('search')
expect(result.stdout).toContain('comment add')
expect(linearIssueContext).not.toHaveBeenCalled()
})
+15 -72
View File
@@ -6,6 +6,11 @@ import {
} from '../../shared/linear-agent-access'
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help'
import {
getRemoteLinearWriteHelp,
tryDispatchRemoteLinearWriteCli
} from './ssh-remote-linear-write-cli'
type ParsedRemoteCli = {
commandPath: string[]
@@ -27,16 +32,11 @@ export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null {
if (!helpPath) {
return null
}
if (helpPath.length === 1 && helpPath[0] === 'linear') {
return LINEAR_HELP
const readHelp = getRemoteLinearReadHelp(helpPath)
if (readHelp) {
return readHelp
}
if (matchesRemoteCommand(helpPath, 'linear', 'issue')) {
return LINEAR_ISSUE_HELP
}
if (matchesRemoteCommand(helpPath, 'linear', 'search')) {
return LINEAR_SEARCH_HELP
}
return null
return getRemoteLinearWriteHelp({ ...parsed, commandPath: helpPath })
}
function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
@@ -49,61 +49,6 @@ function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
return null
}
const LINEAR_HELP = `orca linear
Usage: orca linear <command> [options]
Commands:
issue Read Linear issue context for agents
search Search connected Linear workspaces
Run \`orca linear <command> --help\` for command-specific usage.`
const LINEAR_ISSUE_HELP = `orca linear issue
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
Read Linear issue context for agents
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--current Use the current Orca worktree linked Linear issue
--comments Include threaded Linear comments
--children Include recursive child issues
--depth <n> Child issue depth for --children/--full
--attachments Include attachment metadata and URLs
--relations Include blocking, related, and duplicate links
--full Include all supported V1 issue context within caps
--workspace <id> Connected Linear workspace id
--id <id> Linear issue key, id, or URL
Examples:
$ orca linear issue ENG-123
$ orca linear issue --current --comments
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
const LINEAR_SEARCH_HELP = `orca linear search
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
Search connected Linear workspaces
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--limit <n> Maximum number of rows to return
--workspace <id|all> Connected Linear workspace id, or all
--query <text> Text to search across Linear issues
Examples:
$ orca linear search "auth bug"
$ orca linear search ENG --workspace all --json`
const LINEAR_ISSUE_FLAGS = new Set([
'help',
'json',
@@ -132,7 +77,8 @@ const LINEAR_SEARCH_FLAGS = new Set([
export async function tryDispatchRemoteLinearCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>
env: Record<string, string>,
stdin?: string
): Promise<RpcResponse | null> {
if (isRemoteCommand(parsed, 'linear', 'issue')) {
validateLinearRemoteArgs(parsed, {
@@ -156,6 +102,10 @@ export async function tryDispatchRemoteLinearCli(
workspaceId: optionalString(parsed.flags, 'workspace')
})
}
const writeResponse = await tryDispatchRemoteLinearWriteCli(dispatcher, parsed, env, stdin)
if (writeResponse) {
return writeResponse
}
return null
}
@@ -196,13 +146,6 @@ function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean
return command.every((part, index) => parsed.commandPath[index] === part)
}
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
return (
commandPath.length === command.length &&
command.every((part, index) => commandPath[index] === part)
)
}
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
return value || undefined
@@ -0,0 +1,78 @@
export function getRemoteLinearReadHelp(commandPath: string[]): string | null {
if (commandPath.length === 1 && commandPath[0] === 'linear') {
return LINEAR_HELP
}
if (matchesRemoteCommand(commandPath, 'linear', 'issue')) {
return LINEAR_ISSUE_HELP
}
if (matchesRemoteCommand(commandPath, 'linear', 'search')) {
return LINEAR_SEARCH_HELP
}
return null
}
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
return (
commandPath.length === command.length &&
command.every((part, index) => commandPath[index] === part)
)
}
const LINEAR_HELP = `orca linear
Usage: orca linear <command> [options]
Commands:
issue Read Linear issue context for agents
search Search connected Linear workspaces
status set Set a Linear issue status
comment add Add a comment to a Linear issue
attach Attach a link to a Linear issue
create Create a Linear issue
Run \`orca linear <command> --help\` for command-specific usage.`
const LINEAR_ISSUE_HELP = `orca linear issue
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
Read Linear issue context for agents
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--current Use the current Orca worktree linked Linear issue
--comments Include threaded Linear comments
--children Include recursive child issues
--depth <n> Child issue depth for --children/--full
--attachments Include attachment metadata and URLs
--relations Include blocking, related, and duplicate links
--full Include all supported V1 issue context within caps
--workspace <id> Connected Linear workspace id
--id <id> Linear issue key, id, or URL
Examples:
$ orca linear issue ENG-123
$ orca linear issue --current --comments
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
const LINEAR_SEARCH_HELP = `orca linear search
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
Search connected Linear workspaces
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--limit <n> Maximum number of rows to return
--workspace <id|all> Connected Linear workspace id, or all
--query <text> Text to search across Linear issues
Examples:
$ orca linear search "auth bug"
$ orca linear search ENG --workspace all --json`
+310
View File
@@ -0,0 +1,310 @@
import type { RpcResponse } from '../runtime/rpc/core'
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
type ParsedRemoteCli = {
commandPath: string[]
flags: Map<string, string | boolean>
}
class RemoteLinearWriteArgumentError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RemoteLinearWriteArgumentError'
this.code = code
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const LINEAR_WRITE_FLAGS = new Set(['help', 'json', 'pairing-code', 'environment', 'workspace'])
const LINEAR_TARGET_WRITE_FLAGS = new Set([...LINEAR_WRITE_FLAGS, 'current', 'id'])
const LINEAR_STATUS_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'to'])
const LINEAR_COMMENT_FLAGS = new Set([
...LINEAR_TARGET_WRITE_FLAGS,
'body',
'body-file',
'reply-to',
'write-id'
])
const LINEAR_ATTACH_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'url', 'title', 'write-id'])
const LINEAR_CREATE_FLAGS = new Set([
...LINEAR_WRITE_FLAGS,
'title',
'body',
'body-file',
'team',
'parent',
'parent-current',
'write-id'
])
export function getRemoteLinearWriteHelp(parsed: ParsedRemoteCli): string | null {
const path = parsed.commandPath
if (matchesRemoteCommand(path, 'linear', 'status', 'set')) {
return LINEAR_STATUS_HELP
}
if (matchesRemoteCommand(path, 'linear', 'comment', 'add')) {
return LINEAR_COMMENT_HELP
}
if (matchesRemoteCommand(path, 'linear', 'attach')) {
return LINEAR_ATTACH_HELP
}
if (matchesRemoteCommand(path, 'linear', 'create')) {
return LINEAR_CREATE_HELP
}
return null
}
export async function tryDispatchRemoteLinearWriteCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>,
stdin?: string
): Promise<RpcResponse | null> {
if (isRemoteCommand(parsed, 'linear', 'status', 'set')) {
validateLinearRemoteArgs(parsed, LINEAR_STATUS_FLAGS, ['linear', 'status', 'set'], 1, 'id')
return await call(dispatcher, 'linear.issueSetState', {
...buildRemoteTargetRequest(parsed, env, 3),
to: requiredString(parsed.flags, 'to')
})
}
if (isRemoteCommand(parsed, 'linear', 'comment', 'add')) {
validateLinearRemoteArgs(parsed, LINEAR_COMMENT_FLAGS, ['linear', 'comment', 'add'], 1, 'id')
return await call(dispatcher, 'linear.issueAddComment', {
...buildRemoteTargetRequest(parsed, env, 3),
body: readRemoteBody(parsed.flags, true, stdin),
replyTo: optionalString(parsed.flags, 'reply-to'),
writeId: optionalWriteId(parsed.flags)
})
}
if (isRemoteCommand(parsed, 'linear', 'attach')) {
validateLinearRemoteArgs(parsed, LINEAR_ATTACH_FLAGS, ['linear', 'attach'], 1, 'id')
return await call(dispatcher, 'linear.issueAttachLink', {
...buildRemoteTargetRequest(parsed, env, 2),
url: requiredHttpUrl(parsed.flags, 'url'),
title: optionalString(parsed.flags, 'title'),
writeId: optionalWriteId(parsed.flags)
})
}
if (isRemoteCommand(parsed, 'linear', 'create')) {
validateLinearRemoteArgs(parsed, LINEAR_CREATE_FLAGS, ['linear', 'create'], 0, 'id')
rejectAllWorkspaceForWrite(parsed.flags)
const parentInput = optionalString(parsed.flags, 'parent')
const parentCurrent = parsed.flags.get('parent-current') === true
if (parentInput && parentCurrent) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'Use either --parent or --parent-current, not both'
)
}
const body = readRemoteBody(parsed.flags, false, stdin)
return await call(dispatcher, 'linear.issueCreate', {
title: requiredString(parsed.flags, 'title'),
...(body !== undefined ? { body } : {}),
teamKey: optionalString(parsed.flags, 'team'),
parentInput,
parentCurrent,
workspaceId: optionalString(parsed.flags, 'workspace'),
writeId: optionalWriteId(parsed.flags),
context: buildRemoteContext(env)
})
}
return null
}
function buildRemoteTargetRequest(
parsed: ParsedRemoteCli,
env: Record<string, string>,
positionalStart: number
): Record<string, unknown> {
rejectAllWorkspaceForWrite(parsed.flags)
const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, positionalStart)
const current = parsed.flags.get('current') === true
if (input && current) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'Pass either <id> or --current, not both'
)
}
if (!input && !current) {
throw new RemoteLinearWriteArgumentError(
'linear_issue_required',
'Pass a Linear issue id or --current'
)
}
return {
input,
current,
workspaceId: optionalString(parsed.flags, 'workspace'),
context: buildRemoteContext(env)
}
}
function buildRemoteContext(env: Record<string, string>): Record<string, unknown> {
return {
remote: true,
...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}),
...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {})
}
}
function readRemoteBody(
flags: Map<string, string | boolean>,
required: boolean,
stdin?: string
): string | undefined {
const hasBody = flags.has('body')
const hasBodyFile = flags.has('body-file')
if (hasBody && hasBodyFile) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'Use either --body or --body-file, not both'
)
}
if (hasBodyFile) {
const path = requiredString(flags, 'body-file')
if (path !== '-') {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'SSH Linear writes only support --body-file - for stdin.'
)
}
if (stdin === undefined) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'SSH Linear writes require stdin when using --body-file -.'
)
}
return stdin
}
if (!hasBody) {
if (required) {
throw new RemoteLinearWriteArgumentError('invalid_argument', 'Missing --body or --body-file')
}
return undefined
}
return requiredStringAllowingEmpty(flags, 'body')
}
function rejectAllWorkspaceForWrite(flags: Map<string, string | boolean>): void {
if (optionalString(flags, 'workspace') === 'all') {
throw new RemoteLinearWriteArgumentError(
'linear_invalid_workspace',
'--workspace all is not valid for Linear writes'
)
}
}
function optionalWriteId(flags: Map<string, string | boolean>): string | undefined {
if (!flags.has('write-id')) {
return undefined
}
const writeId = requiredString(flags, 'write-id')
if (!UUID_PATTERN.test(writeId)) {
throw new RemoteLinearWriteArgumentError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
function requiredHttpUrl(flags: Map<string, string | boolean>, name: string): string {
const value = requiredString(flags, name)
try {
const parsed = new URL(value)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return value
}
} catch {
// Fall through to stable Linear validation error.
}
throw new RemoteLinearWriteArgumentError(
'linear_invalid_url',
'--url must be an absolute http(s) URL'
)
}
function validateLinearRemoteArgs(
parsed: ParsedRemoteCli,
allowedFlags: ReadonlySet<string>,
command: string[],
maxPositionals: number,
positionalFlag: string
): void {
for (const flag of parsed.flags.keys()) {
if (!allowedFlags.has(flag)) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
`Unknown flag --${flag} for command: ${command.join(' ')}`
)
}
}
const positionals = parsed.commandPath.slice(command.length)
if (positionals.length > maxPositionals) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
`Unknown command: ${parsed.commandPath.join(' ')}`
)
}
if (positionals.length > 0 && parsed.flags.has(positionalFlag)) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
`Pass --${positionalFlag} either positionally or as a flag, not both.`
)
}
}
function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean {
return command.every((part, index) => parsed.commandPath[index] === part)
}
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
return (
commandPath.length === command.length &&
command.every((part, index) => commandPath[index] === part)
)
}
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
return value || undefined
}
function requiredString(flags: Map<string, string | boolean>, name: string): string {
const value = optionalString(flags, name)
if (!value) {
throw new RemoteLinearWriteArgumentError('invalid_argument', `Missing --${name}`)
}
return value
}
function requiredStringAllowingEmpty(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
if (typeof value === 'string') {
return value
}
throw new RemoteLinearWriteArgumentError('invalid_argument', `Missing --${name}`)
}
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
async function call(
dispatcher: RpcDispatcher,
method: string,
params?: Record<string, unknown>
): Promise<RpcResponse> {
return await dispatcher.dispatch({
id: `remote-cli-${Date.now()}`,
authToken: 'remote-cli',
method,
params
})
}
const LINEAR_STATUS_HELP = `orca linear status set\n\nUsage: orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]\n\nSet a Linear issue status`
const LINEAR_COMMENT_HELP = `orca linear comment add\n\nUsage: orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]\n\nAdd a comment to a Linear issue`
const LINEAR_ATTACH_HELP = `orca linear attach\n\nUsage: orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]\n\nAttach a link to a Linear issue`
const LINEAR_CREATE_HELP = `orca linear create\n\nUsage: orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key>] [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]\n\nCreate a Linear issue`
+6 -3
View File
@@ -13,6 +13,7 @@ export type RemoteOrcaCliRequest = {
argv: string[]
cwd: string
env: Record<string, string>
stdin?: string
}
export type RemoteOrcaCliResult = {
@@ -37,6 +38,7 @@ const REMOTE_BOOLEAN_FLAGS = new Set([
'inject',
'json',
'relations',
'parent-current',
'unread',
'wait'
])
@@ -54,7 +56,7 @@ export async function runRemoteOrcaCli(
}
try {
const response = await dispatchRemoteCli(dispatcher, parsed, request.env)
const response = await dispatchRemoteCli(dispatcher, parsed, request.env, request.stdin)
const formatted = json
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
: formatRemoteCli(response)
@@ -87,10 +89,11 @@ export async function runRemoteOrcaCli(
async function dispatchRemoteCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>
env: Record<string, string>,
stdin?: string
): Promise<RpcResponse> {
const command = parsed.commandPath.join(' ')
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env)
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin)
if (linearResponse) {
return linearResponse
}
+265 -7
View File
@@ -13,7 +13,15 @@ type TestWindow = {
webContents: { send: ReturnType<typeof vi.fn> }
}
const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } = vi.hoisted(() => ({
const {
appMock,
browserWindowMock,
checkOrcaStarredMock,
starOrcaMock,
trackMock,
getCohortAtEmitMock,
ipcMainHandleMock
} = vi.hoisted(() => ({
appMock: {
getVersion: vi.fn(() => '1.2.3')
},
@@ -21,6 +29,9 @@ const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } =
getAllWindows: vi.fn<() => TestWindow[]>(() => [])
},
checkOrcaStarredMock: vi.fn(),
starOrcaMock: vi.fn(),
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 3 })),
ipcMainHandleMock: vi.fn()
}))
@@ -33,7 +44,16 @@ vi.mock('electron', () => ({
}))
vi.mock('../github/client', () => ({
checkOrcaStarred: checkOrcaStarredMock
checkOrcaStarred: checkOrcaStarredMock,
starOrca: starOrcaMock
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
type AgentStartedListener = (totalAgentsSpawned: number) => void
@@ -131,6 +151,11 @@ describe('StarNagService', () => {
browserWindowMock.getAllWindows.mockReturnValue([])
checkOrcaStarredMock.mockReset()
checkOrcaStarredMock.mockResolvedValue(false)
starOrcaMock.mockReset()
starOrcaMock.mockResolvedValue(true)
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 })
ipcMainHandleMock.mockReset()
consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
@@ -150,7 +175,9 @@ describe('StarNagService', () => {
emitAgentStarted(46)
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
})
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
@@ -171,7 +198,18 @@ describe('StarNagService', () => {
emitAgentStarted(45)
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'web'
})
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
outcome: 'shown',
source: 'threshold',
mode: 'web',
threshold: STAR_NAG_INITIAL_THRESHOLD,
agents_since_baseline: 35,
agents_since_baseline_bucket: '35-69',
nth_repo_added: 3
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -203,13 +241,16 @@ describe('StarNagService', () => {
await flushAsyncWork()
expect(consoleInfoMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
emitAgentStarted(46)
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -282,7 +323,9 @@ describe('StarNagService', () => {
browserWindowMock.getAllWindows.mockReturnValue([window])
forceShow()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -405,7 +448,9 @@ describe('StarNagService', () => {
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'web'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -458,4 +503,217 @@ describe('StarNagService', () => {
source: 'force_show'
})
})
it('emits shown and already_starred_suppressed outcomes with cohort context', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service, emitAgentStarted } = createHarness()
service.start()
emitAgentStarted(45)
await flushAsyncWork()
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
outcome: 'shown',
source: 'threshold',
mode: 'gh',
threshold: STAR_NAG_INITIAL_THRESHOLD,
agents_since_baseline: 35,
agents_since_baseline_bucket: '35-69',
nth_repo_added: 3
})
trackMock.mockClear()
checkOrcaStarredMock.mockResolvedValue(true)
const next = createHarness()
next.service.start()
next.emitAgentStarted(45)
await flushAsyncWork()
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
outcome: 'already_starred_suppressed',
source: 'threshold',
mode: 'gh',
threshold: STAR_NAG_INITIAL_THRESHOLD,
agents_since_baseline: 35,
agents_since_baseline_bucket: '35-69',
nth_repo_added: 3
})
})
it('emits dismissed, disabled, and opened_web as distinct main-owned outcomes', () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const dismissed = createHarness()
dismissed.service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
getIpcHandler('star-nag:dismiss')()
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
outcome: 'dismissed',
source: 'force_show',
mode: 'gh',
threshold: STAR_NAG_INITIAL_THRESHOLD,
agents_since_baseline: 35,
agents_since_baseline_bucket: '35-69',
nth_repo_added: 3,
next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2
})
trackMock.mockClear()
ipcMainHandleMock.mockClear()
const disabled = createHarness()
disabled.service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
getIpcHandler('star-nag:disable')()
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'disabled', mode: 'gh' })
)
trackMock.mockClear()
ipcMainHandleMock.mockClear()
const opened = createHarness()
opened.service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
getIpcHandler('star-nag:openWeb')()
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
)
})
it('emits direct-star attempted and succeeded outcomes plus app_starred_orca', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
const ok = await getIpcHandler('star-nag:starOrca')()
expect(ok).toBe(true)
expect(ui.starNagCompleted).toBe(true)
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'star_attempted', mode: 'gh' })
)
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
)
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
source: 'star_nag',
nth_repo_added: 3
})
})
it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
getCohortAtEmitMock
.mockReturnValueOnce({ nth_repo_added: 2 })
.mockReturnValueOnce({ nth_repo_added: 4 })
const { service } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
await getIpcHandler('star-nag:starOrca')()
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', nth_repo_added: 2 })
)
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'star_succeeded', nth_repo_added: 2 })
)
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
source: 'star_nag',
nth_repo_added: 4
})
})
it('records success and completion when direct star resolves after dismissal cleared the visible session', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const deferredStar = createDeferred<boolean>()
starOrcaMock.mockReturnValue(deferredStar.promise)
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
const starPromise = getIpcHandler('star-nag:starOrca')()
getIpcHandler('star-nag:dismiss')()
deferredStar.resolve(true)
await expect(starPromise).resolves.toBe(true)
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
)
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
source: 'star_nag',
nth_repo_added: 3
})
expect(ui.starNagCompleted).toBe(true)
})
it('clears the in-flight direct-star guard after thrown attempts so the user can retry', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
starOrcaMock.mockRejectedValueOnce(new Error('gh failed')).mockResolvedValueOnce(true)
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
const starFromNag = getIpcHandler('star-nag:starOrca')
await expect(starFromNag()).rejects.toThrow('gh failed')
await expect(starFromNag()).resolves.toBe(true)
expect(starOrcaMock).toHaveBeenCalledTimes(2)
expect(ui.starNagCompleted).toBe(true)
})
it('records failed direct star before web fallback and guards duplicate in-flight attempts', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const deferredStar = createDeferred<boolean>()
starOrcaMock.mockReturnValue(deferredStar.promise)
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
const starFromNag = getIpcHandler('star-nag:starOrca')
const first = starFromNag()
const second = starFromNag()
deferredStar.resolve(false)
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
const starAttempts = trackMock.mock.calls.filter(
([name, payload]) =>
name === 'star_nag_outcome' &&
(payload as { outcome?: string }).outcome === 'star_attempted'
)
expect(starAttempts).toHaveLength(1)
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'star_failed', mode: 'gh' })
)
getIpcHandler('star-nag:openWeb')()
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
)
expect(ui.starNagCompleted).toBe(true)
})
})
+126 -9
View File
@@ -1,14 +1,22 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import { checkOrcaStarred } from '../github/client'
import { checkOrcaStarred, starOrca } from '../github/client'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import {
bucketStarNagAgentsSinceBaseline,
type StarNagOutcome,
type StarNagPromptMode,
type StarNagPromptSource
} from '../../shared/star-nag-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
type StarNagPromptSource = 'threshold' | 'force_show'
type StarNagPromptMode = 'gh' | 'web'
type StarNagPromptContext = Omit<EventProps<'star_nag_outcome'>, 'outcome' | 'next_threshold'>
type StarNagPromptSession = {
source: StarNagPromptSource
type StarNagPromptSession = StarNagPromptContext & {
starAttemptPromise?: Promise<boolean>
}
/**
@@ -36,8 +44,9 @@ export class StarNagService {
// resolving.
private evaluating = false
private pendingForceShow = false
// Why: dismissal backoff should only apply to a prompt that was actually
// delivered, and the dismissal payload needs the delivered prompt source.
// Why: dismissal backoff and action telemetry must use the prompt context
// that was delivered, not whatever threshold/source happens to be current
// when the renderer later reports a user action.
private promptSession: StarNagPromptSession | null = null
constructor(store: Store, stats: StatsCollector) {
@@ -65,7 +74,9 @@ export class StarNagService {
registerIpcHandlers(): void {
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
ipcMain.handle('star-nag:complete', () => this.markCompleted())
ipcMain.handle('star-nag:disable', () => this.markCompleted())
ipcMain.handle('star-nag:disable', () => this.disable())
ipcMain.handle('star-nag:openWeb', () => this.openWeb())
ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag())
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
}
@@ -134,6 +145,7 @@ export class StarNagService {
return
}
if (starred) {
this.trackAlreadyStarredSuppressed(source)
// Already starred somewhere — lock in the permanent suppression so we
// stop recomputing thresholds on every spawn.
this.markCompleted()
@@ -167,13 +179,67 @@ export class StarNagService {
this.promptSession = null
return false
}
const context = this.createPromptContext(source, mode)
win.webContents.send('star-nag:show', { mode })
this.promptVisible = true
this.promptSession = { source }
this.promptSession = context
this.trackOutcome('shown')
this.logConsoleEvent('star_nag_shown', source)
return true
}
private createPromptContext(
source: StarNagPromptSource,
mode: StarNagPromptMode
): StarNagPromptContext {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const agentsSinceBaseline = Math.max(
0,
this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
)
return {
source,
mode,
threshold,
agents_since_baseline: agentsSinceBaseline,
agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
...getCohortAtEmit()
}
}
private trackOutcome(
outcome: StarNagOutcome,
options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
): void {
const session = this.promptSession
if (!session) {
return
}
this.trackSessionOutcome(session, outcome, options)
}
private trackSessionOutcome(
session: StarNagPromptSession,
outcome: StarNagOutcome,
options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
): void {
const { starAttemptPromise: _starAttemptPromise, ...context } = session
track('star_nag_outcome', {
...context,
outcome,
...(options.mode === undefined ? {} : { mode: options.mode }),
...(options.nextThreshold === undefined ? {} : { next_threshold: options.nextThreshold })
})
}
private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
track('star_nag_outcome', {
...this.createPromptContext(source, 'gh'),
outcome: 'already_starred_suppressed'
})
}
private logConsoleEvent(
event: 'star_nag_shown' | 'star_nag_dismissed',
source: StarNagPromptSource,
@@ -211,6 +277,7 @@ export class StarNagService {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const nextThreshold = threshold * 2
this.trackOutcome('dismissed', { nextThreshold })
this.logConsoleEvent('star_nag_dismissed', session.source, nextThreshold)
this.store.updateUI({
starNagNextThreshold: nextThreshold,
@@ -220,6 +287,56 @@ export class StarNagService {
this.promptSession = null
}
private disable(): void {
this.trackOutcome('disabled')
this.markCompleted()
}
private openWeb(): void {
this.trackOutcome('opened_web', { mode: 'web' })
this.markCompleted()
}
private async starOrcaFromNag(): Promise<boolean> {
const session = this.promptSession
if (!session) {
return false
}
if (session.starAttemptPromise) {
return session.starAttemptPromise
}
const attempt = this.runStarOrcaAttempt(session)
session.starAttemptPromise = attempt
try {
return await attempt
} finally {
if (this.promptSession === session) {
delete session.starAttemptPromise
}
}
}
private async runStarOrcaAttempt(session: StarNagPromptSession): Promise<boolean> {
this.trackSessionOutcome(session, 'star_attempted', { mode: 'gh' })
const starred = await starOrca()
if (!starred) {
if (this.promptSession === session) {
this.trackSessionOutcome(session, 'star_failed', { mode: 'gh' })
session.mode = 'web'
}
return false
}
this.trackSessionOutcome(session, 'star_succeeded', { mode: 'gh' })
// Why: app_starred_orca remains the canonical cross-surface success event;
// star_nag_outcome is only the nag-funnel companion.
track('app_starred_orca', {
source: 'star_nag',
...getCohortAtEmit()
})
this.markCompleted()
return true
}
/** User successfully starred or opted out → never nag again. */
private markCompleted(): void {
this.store.updateUI({ starNagCompleted: true })
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS,
LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS,
startFirstWindowStartupServices
} from './first-window-startup-services'
@@ -83,7 +84,49 @@ describe('startFirstWindowStartupServices', () => {
expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error))
})
it('fails open the first window and local PTY startup while aborting hung services', async () => {
it('opens the first window at the window timeout without aborting a slow daemon or opening the PTY gate', async () => {
vi.useFakeTimers()
const onDaemonError = vi.fn()
let daemonSignal: AbortSignal | undefined
let resolveDaemon!: () => void
try {
const started = startFirstWindowStartupServices({
startDaemonPtyProvider: (signal) => {
daemonSignal = signal
return new Promise<void>((resolve) => {
resolveDaemon = resolve
})
},
startAgentHookServer: () => Promise.resolve(),
onDaemonError,
onAgentHookServerError: vi.fn()
})
let ptyGateOpened = false
void started.localPtyReady.then(() => {
ptyGateOpened = true
})
await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
await expect(started.firstWindowReady).resolves.toBeUndefined()
// Why: opening the PTY gate before the daemon attempt finishes would
// spawn non-restorable LocalPtyProvider fallback terminals (#5232).
expect(ptyGateOpened).toBe(false)
expect(daemonSignal?.aborted).toBe(false)
expect(onDaemonError).not.toHaveBeenCalled()
resolveDaemon()
await expect(started.localPtyReady).resolves.toBeUndefined()
expect(daemonSignal?.aborted).toBe(false)
expect(onDaemonError).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('fails open the local PTY gate at the hard cap while aborting hung services', async () => {
vi.useFakeTimers()
const onDaemonError = vi.fn()
const onAgentHookServerError = vi.fn()
@@ -101,7 +144,7 @@ describe('startFirstWindowStartupServices', () => {
})
await Promise.resolve()
await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
await vi.advanceTimersByTimeAsync(LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS)
await expect(started.firstWindowReady).resolves.toBeUndefined()
await expect(started.localPtyReady).resolves.toBeUndefined()
@@ -16,6 +16,12 @@ type FirstWindowStartupServicesResult = {
}
export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000
// Why: a slow (but succeeding) daemon start must not flip terminals to the
// LocalPtyProvider fallback — local PTYs are killed on quit, so panes bound to
// them lose their daemon sessions permanently (#5232). The PTY gate therefore
// waits for the daemon attempt itself and only fail-opens at a hard cap that
// exists solely as a deadlock backstop.
export const LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS = 60_000
function startService(
label: string,
@@ -60,33 +66,40 @@ export function startFirstWindowStartupServices({
}: FirstWindowStartupServices): FirstWindowStartupServicesResult {
// Why: daemon startup and hook-server binding are independent, but both gate
// restored terminals; run them together so cold-start latency is max(), not sum().
// The first window and local PTY startup both fail open after the timeout.
// The timeout also aborts slow services so late daemon swaps cannot strand
// any fallback LocalPtyProvider PTYs that spawn after the barrier opens.
// The first window fails open quickly so the user sees the app; the local PTY
// gate waits for the services themselves (a slow daemon must not flip spawns
// to the non-restorable LocalPtyProvider fallback) and only fails open at the
// hard cap, which also aborts the services so a late daemon swap cannot
// strand any fallback PTYs that spawn after the gate opens.
const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError)
const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError)
const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined)
let timeout: ReturnType<typeof setTimeout> | null = null
let resolveTimedOut!: () => void
const timedOut = new Promise<void>((resolve) => {
resolveTimedOut = resolve
let windowTimeout: ReturnType<typeof setTimeout> | null = null
let failOpenTimeout: ReturnType<typeof setTimeout> | null = null
const servicesSettled = allServicesReady.finally(() => {
if (windowTimeout) {
clearTimeout(windowTimeout)
}
if (failOpenTimeout) {
clearTimeout(failOpenTimeout)
}
})
const firstWindowReady = Promise.race([
allServicesReady.finally(() => {
if (timeout) {
clearTimeout(timeout)
}
}),
servicesSettled,
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
windowTimeout = setTimeout(resolve, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
})
])
const localPtyReady = Promise.race([
servicesSettled,
new Promise<void>((resolve) => {
failOpenTimeout = setTimeout(() => {
daemon.reportTimeout()
hooks.reportTimeout()
resolveTimedOut()
resolve()
}, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
}, LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS)
})
])
const localPtyReady = Promise.race([allServicesReady, timedOut])
return { firstWindowReady, localPtyReady }
}
@@ -15,6 +15,7 @@ describe('onboarding feature setup telemetry validation', () => {
const selection = {
browser_use: true,
computer_use: false,
linear_tickets: true,
orchestration: true,
selected_count: 2
}
@@ -45,6 +46,7 @@ describe('onboarding feature setup telemetry validation', () => {
validate('onboarding_feature_setup_terminal_opened', {
browser_use: true,
computer_use: false,
linear_tickets: false,
orchestration: true,
selected_count: 2,
command: 'npx skills add https://github.com/stablyai/orca --global'
@@ -64,6 +66,7 @@ describe('onboarding feature setup telemetry validation', () => {
validate('onboarding_feature_setup_run', {
browser_use: false,
computer_use: false,
linear_tickets: false,
orchestration: false,
selected_count: 3,
cli_touched: false,
@@ -77,6 +80,7 @@ describe('onboarding feature setup telemetry validation', () => {
validate('onboarding_feature_setup_terminal_opened', {
browser_use: true,
computer_use: false,
linear_tickets: false,
orchestration: true,
selected_count: 1
} as never).ok
+37
View File
@@ -41,6 +41,43 @@ describe('validate', () => {
expect(result.ok).toBe(true)
})
it('accepts a well-formed star_nag_outcome payload with cohort context', () => {
const result = validate('star_nag_outcome', {
outcome: 'opened_web',
source: 'force_show',
mode: 'web',
threshold: 35,
agents_since_baseline: 42,
agents_since_baseline_bucket: '35-69',
nth_repo_added: 4
})
expect(result.ok).toBe(true)
})
it('rejects malformed star_nag_outcome payloads', () => {
expect(
validate('star_nag_outcome', {
outcome: 'opened_web',
source: 'force_show',
mode: 'web',
threshold: 35,
agents_since_baseline: 42,
agents_since_baseline_bucket: '35-69',
raw_error: 'nope'
} as never).ok
).toBe(false)
expect(
validate('star_nag_outcome', {
outcome: 'opened_web',
source: 'force_show',
mode: 'web',
threshold: 0,
agents_since_baseline: 42,
agents_since_baseline_bucket: '35-69'
} as never).ok
).toBe(false)
})
it('drops unknown event names', () => {
const result = validate('not_a_real_event' as never, {})
expect(result.ok).toBe(false)
+5 -12
View File
@@ -127,15 +127,6 @@ import type {
Project,
Repo,
ProjectGroup,
ProjectHostSetup,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
FolderWorkspace,
ProjectGroupImportResult,
ProjectGroupImportMode,
@@ -1655,6 +1646,8 @@ export type PreloadApi = {
dismiss: () => Promise<void>
complete: () => Promise<void>
disable: () => Promise<void>
openWeb: () => Promise<void>
starOrca: () => Promise<boolean>
forceShow: () => Promise<void>
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
@@ -1748,9 +1741,9 @@ export type PreloadApi = {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
getWslInstallStatus: () => Promise<CliInstallStatus>
installWsl: () => Promise<CliInstallStatus>
removeWsl: () => Promise<CliInstallStatus>
getWslInstallStatus: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
installWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
removeWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
}
agentHooks: {
claudeStatus: () => Promise<AgentHookInstallStatus>
+8 -4
View File
@@ -1489,6 +1489,8 @@ const api = {
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
},
@@ -1605,10 +1607,12 @@ const api = {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove'),
getWslInstallStatus: (): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:getWslInstallStatus'),
installWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:installWsl'),
removeWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:removeWsl')
getWslInstallStatus: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:getWslInstallStatus', args),
installWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:installWsl', args),
removeWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:removeWsl', args)
},
agentHooks: {
+17 -3
View File
@@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
import { pickRemoteCliEnv } from './remote-cli-env'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
const SOCK_NAME = 'relay.sock'
@@ -213,8 +214,9 @@ function runConnectMode(sockPath: string): void {
})
}
function runOrcaCliMode(sockPath: string, argv: string[]): void {
async function runOrcaCliMode(sockPath: string, argv: string[]): Promise<void> {
const myVersion = readLaunchVersion()
const stdin = shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined
const sock = createConnection({ path: sockPath })
let nextSeq = 1
let highestReceivedSeq = 0
@@ -230,7 +232,8 @@ function runOrcaCliMode(sockPath: string, argv: string[]): void {
params: {
argv,
cwd: process.cwd(),
env
env,
...(stdin !== undefined ? { stdin } : {})
}
},
nextSeq++,
@@ -299,6 +302,17 @@ function runOrcaCliMode(sockPath: string, argv: string[]): void {
})
}
async function readOrcaCliStdin(): Promise<string | undefined> {
if (process.stdin.isTTY) {
return undefined
}
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')
}
// ── Normal mode ──────────────────────────────────────────────────────
async function main(): Promise<void> {
@@ -312,7 +326,7 @@ async function main(): Promise<void> {
}
if (cliMode) {
const marker = process.argv.indexOf('--orca-cli')
runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : [])
await runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : [])
return
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
describe('shouldReadRemoteCliStdin', () => {
it('reads stdin only for body-file stdin requests', () => {
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', '-'])).toBe(true)
expect(shouldReadRemoteCliStdin(['linear', 'create', '--body-file=-'])).toBe(true)
expect(shouldReadRemoteCliStdin(['status'])).toBe(false)
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body', 'done'])).toBe(false)
expect(shouldReadRemoteCliStdin(['linear', 'issue', '--body-file', '-'])).toBe(false)
expect(
shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--help', '--body-file', '-'])
).toBe(false)
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', 'body.md'])).toBe(
false
)
})
})
+44
View File
@@ -0,0 +1,44 @@
export function shouldReadRemoteCliStdin(argv: string[]): boolean {
if (argv.includes('--help') || argv.includes('-h')) {
return false
}
const commandPath = parseRemoteCliCommandPath(argv)
if (!isLinearBodyWriteCommand(commandPath)) {
return false
}
return argv.some((part, index) => {
if (part === '--body-file') {
return argv[index + 1] === '-'
}
return part === '--body-file=-'
})
}
const REMOTE_STDIN_BOOLEAN_FLAGS = new Set(['current', 'help', 'json', 'parent-current'])
function parseRemoteCliCommandPath(argv: string[]): string[] {
const commandPath: string[] = []
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const assignment = token.slice(2)
if (assignment.includes('=')) {
continue
}
const next = argv[index + 1]
if (!REMOTE_STDIN_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) {
index += 1
}
}
return commandPath
}
function isLinearBodyWriteCommand(commandPath: string[]): boolean {
if (commandPath[0] !== 'linear') {
return false
}
return (commandPath[1] === 'comment' && commandPath[2] === 'add') || commandPath[1] === 'create'
}

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