Merge remote-tracking branch 'origin/main' into brennanb2025/issue-5796-codex-auto-approval-notifications

# Conflicts:
#	src/renderer/src/hooks/agent-hook-completion-notifications.test.ts
#	src/renderer/src/i18n/locales/zh.json
This commit is contained in:
brennanb2025
2026-06-26 21:30:39 -07:00
666 changed files with 48721 additions and 9246 deletions
+49 -8
View File
@@ -15,6 +15,7 @@ import { join } from 'path'
import { spawnSync } from 'child_process'
import {
buildWindowsAgentHookPostCommand,
buildWindowsAgentHookCurlPostCommand,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
@@ -339,6 +340,9 @@ describe('wrapPosixHookCommand', () => {
})
describe('wrapWindowsHookCommand', () => {
const qualifiedPowerShellCommand =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
function decodeWindowsHookCommand(command: string): string {
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
expect(encodedCommand).toBeTruthy()
@@ -347,7 +351,8 @@ describe('wrapWindowsHookCommand', () => {
it('invokes the .cmd through an encoded PowerShell command', () => {
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/)
expect(command).toMatch(qualifiedPowerShellCommand)
expect(command).not.toMatch(/^powershell\b/i)
expect(decodeWindowsHookCommand(command)).toBe(
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
)
@@ -358,6 +363,7 @@ describe('wrapWindowsHookCommand', () => {
// the whole path inside the encoded command so shells do not split it.
it('preserves spaces in the script path (user profile with space case)', () => {
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
expect(cmd).toMatch(qualifiedPowerShellCommand)
expect(decodeWindowsHookCommand(cmd)).toBe(
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
)
@@ -390,15 +396,50 @@ describe('wrapWindowsHookCommand', () => {
})
describe('buildWindowsAgentHookPostCommand', () => {
it('forces UTF-8 for redirected hook stdin and POST bodies', () => {
it('posts hook stdin through bounded curl without spawning PowerShell', () => {
const command = buildWindowsAgentHookPostCommand('codex')
expect(command).toContain('[Console]::InputEncoding=$utf8')
expect(command).toContain('[Console]::OutputEncoding=$utf8')
expect(command).toContain('$bodyBytes=$utf8.GetBytes($body)')
expect(command).toContain("-ContentType 'application/json; charset=utf-8'")
expect(command).toContain('-TimeoutSec 2')
expect(command).toContain('"%SystemRoot%\\System32\\curl.exe" -sS -X POST')
expect(command).toContain('--connect-timeout 0.5 --max-time 1.5')
expect(command).toContain('-H "Content-Type: application/x-www-form-urlencoded"')
expect(command).toContain('-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"')
expect(command).toContain('--data-urlencode "paneKey=%ORCA_PANE_KEY%"')
expect(command).toContain('--data-urlencode "payload@-"')
expect(command).toContain('/hook/codex')
expect(command).not.toContain("'Content-Type'='application/json'")
expect(command).not.toContain('powershell')
expect(command).not.toContain('Invoke-WebRequest')
})
it('does not resolve curl from the current directory or PATH', () => {
const command = buildWindowsAgentHookPostCommand('gemini')
expect(command).toMatch(/^"%SystemRoot%\\System32\\curl\.exe"/)
expect(command).not.toMatch(/^curl\.exe\b/)
})
})
describe('buildWindowsAgentHookCurlPostCommand', () => {
it('posts form fields via curl.exe and reads the payload from stdin', () => {
const command = buildWindowsAgentHookCurlPostCommand('codex')
// Why: the fast path must not spawn a second PowerShell; that startup cost
// is the regression this replaces.
expect(command).not.toMatch(/powershell/i)
expect(command).toContain('%SystemRoot%\\System32\\curl.exe')
expect(command).toContain('http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/codex')
expect(command).toContain('-H "Content-Type: application/x-www-form-urlencoded"')
expect(command).toContain('-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"')
expect(command).toContain('--data-urlencode "paneKey=%ORCA_PANE_KEY%"')
expect(command).toContain('--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"')
// Why: `payload@-` makes curl read raw bytes from stdin and urlencode them,
// so UTF-8 prompts survive without a code-page conversion.
expect(command).toContain('--data-urlencode "payload@-"')
// Why: same dead-listener bound as the POSIX hook so a stalled server can't
// hold up the agent.
expect(command).toContain('--connect-timeout 0.5 --max-time 1.5')
})
it('targets the requested hook source endpoint', () => {
expect(buildWindowsAgentHookCurlPostCommand('grok')).toContain('/hook/grok')
})
})
+51 -11
View File
@@ -147,23 +147,63 @@ function quotePowerShellString(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
// Why: Windows splits a raw hook command on whitespace, so a user profile path
// like `C:\Users\Jane Doe` makes the agent try to execute `C:\Users\Jane` and
// fail with exit code 1. Keep the script path inside an encoded PowerShell
// command so cmd.exe never gets a chance to expand legal path characters like
// `%` or `^` before the .cmd is invoked. #6078.
function getWindowsPowerShellExecutablePath(): string {
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
// Why: PATH lookup lets a worktree-local powershell.exe hijack hook payloads.
// Forward slashes keep this absolute path shell-friendly for cmd.exe and Git Bash.
return `${systemRoot.replaceAll('\\', '/')}/System32/WindowsPowerShell/v1.0/powershell.exe`
}
export function wrapWindowsHookCommand(scriptPath: string): string {
// Why: most Windows agents run hooks through Git Bash or another shell that
// mangles a raw backslash path. Codex has its own cmd.exe-safe fast path; the
// shared wrapper keeps the encoded launcher for every other agent.
const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE`
const encodedCommand = Buffer.from(command, 'utf16le').toString('base64')
return `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Windows PowerShell 5.1 defaults redirected stdin/request bodies to the
// active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read
// and POST or CJK prompts arrive in Orca as literal question marks. Timeout
// caps best-effort hook posts if the local listener stalls.
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/${source}') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
// Qualify curl so a repo-local curl.exe cannot hijack hook payloads.
return [
`"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`,
' --connect-timeout 0.5 --max-time 1.5 ^',
' -H "Content-Type: application/x-www-form-urlencoded" ^',
' -H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%" ^',
' --data-urlencode "paneKey=%ORCA_PANE_KEY%" ^',
' --data-urlencode "tabId=%ORCA_TAB_ID%" ^',
' --data-urlencode "launchToken=%ORCA_AGENT_LAUNCH_TOKEN%" ^',
' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^',
' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^',
' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^',
' --data-urlencode "payload@-" >nul 2>nul'
].join('\r\n')
}
// Why: status hooks fire up to 6× per turn; spawning PowerShell per post adds
// ~300ms of interpreter startup each, which Codex 0.140's synchronous "Running
// <event> hook" rows make visible. curl.exe (Windows 10 1803+) posts the same
// form fields as the POSIX hook and reads the raw payload from stdin via
// `--data-urlencode payload@-`, so UTF-8 (e.g. CJK prompts) survives byte-for-
// byte without the code-page translation that previously forced PowerShell.
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
return [
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
`"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`,
'--connect-timeout 0.5 --max-time 1.5',
'-H "Content-Type: application/x-www-form-urlencoded"',
'-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"',
'--data-urlencode "paneKey=%ORCA_PANE_KEY%"',
'--data-urlencode "tabId=%ORCA_TAB_ID%"',
'--data-urlencode "launchToken=%ORCA_AGENT_LAUNCH_TOKEN%"',
'--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"',
'--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"',
'--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"',
'--data-urlencode "payload@-"',
'>nul 2>&1'
].join(' ')
}
export function removeManagedCommands(
@@ -192,7 +192,16 @@ describe('remote hook service installers', () => {
})
it('installs remote Codex hooks with matching trust entries', async () => {
const { sftp, fs } = createFakeSftp()
const { sftp, fs } = createFakeSftp({
'/home/dev/.codex/hooks.json': `${JSON.stringify({
hooks: {},
_managed: {
'external-manager': {
Stop: [0]
}
}
})}\n`
})
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
@@ -200,7 +209,9 @@ describe('remote hook service installers', () => {
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
_managed?: unknown
}
expect(hooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
+248 -1
View File
@@ -434,6 +434,74 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not let late Claude tool hooks with explicit prompt resurrect an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'UserPromptSubmit',
payload: {
state: 'working',
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'Do I have gpu acceleration on on my terminal?',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude',
toolName: 'Read',
toolInput: 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'Do I have gpu acceleration on on my terminal?',
agentType: 'claude',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a new prompt after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -487,6 +555,64 @@ describe('AgentHookServer listener replay', () => {
}
})
it('allows a Claude follow-up prompt after an inferred interrupt to keep working', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'first Claude turn', agentType: 'claude' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'first Claude turn',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'second queued Claude turn', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'second queued Claude turn',
agentType: 'claude',
interrupted: undefined,
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('allows an immediate same-prompt retry after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -542,7 +668,125 @@ describe('AgentHookServer listener replay', () => {
}
})
it('allows a same-prompt working hook after the stale suppression window', () => {
it('suppresses same-turn Claude tool progress after the stale suppression window', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'repeat task', agentType: 'claude' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'repeat task',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: 'repeat task',
agentType: 'claude',
toolName: 'bash',
toolInput: '/bin/sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'repeat task',
agentType: 'claude',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('allows generic non-explicit same-prompt working after the stale suppression window', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'repeat task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
interrupted: undefined,
receivedAt: 16_501,
stateStartedAt: 16_501
})
])
} finally {
vi.useRealTimers()
}
})
it('allows non-Claude tool-context working after the stale suppression window', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
@@ -592,6 +836,9 @@ describe('AgentHookServer listener replay', () => {
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
interrupted: undefined,
toolName: 'bash',
toolInput: '/bin/sleep 90',
receivedAt: 16_501,
stateStartedAt: 16_501
})
+14
View File
@@ -108,6 +108,7 @@ const LAST_STATUS_FILE_VERSION = 2
// hook-server batching; quit-time uses flushStatusPersistSync() for the
// guaranteed final flush.
const STATUS_PERSIST_DEBOUNCE_MS = 250
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// Why: bound the on-disk file's growth across many sessions. PTY-teardown
@@ -271,6 +272,18 @@ function trackEmptyPaneKeyHook(body: unknown): void {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
}
function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean {
if (next.payload.state !== 'working') {
return false
}
if (next.payload.agentType !== 'claude') {
return false
}
// Why: a same-prompt retry is another UserPromptSubmit, while late Claude
// progress after Ctrl+C arrives as tool lifecycle work for the old turn.
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
}
function paneCacheKeyTabId(key: string): string | null {
const paneKey = key.split('\0', 1)[0] ?? key
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
@@ -737,6 +750,7 @@ export class AgentHookServer {
previous.payload.agentType === effectivePayload.payload.agentType &&
previous.payload.prompt === effectivePayload.payload.prompt &&
(effectivePayload.isReplay === true ||
isToolProgressWorkingAfterInterrupt(effectivePayload) ||
(effectivePayload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) {
@@ -0,0 +1,253 @@
import { createReadStream } from 'fs'
import { readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import { extname, join } from 'path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
// Reading a few lines of one transcript per project dir is enough to learn that
// dir's cwd; cap both so a giant or cwd-less transcript can't stall the scan.
const REPRESENTATIVE_CWD_LINE_LIMIT = 200
const REPRESENTATIVE_FILE_LIMIT = 3
const CLAUDE_EXTENSIONS = new Set(['.jsonl'])
/**
* Fully include the transcripts of Claude project directories whose cwd falls
* inside the active workspace/project paths.
*
* Why: Claude organizes `~/.claude/projects/<cwd-encoded>/` one directory per
* cwd. The global scan is recency-capped, so a project the user hasn't touched
* recently can drop off the list entirely even though `claude --resume` still
* finds it. For scoped panel views we resolve each project dir's cwd cheaply and
* bypass the cap for the ones that belong to the active scope.
*/
export async function discoverInScopeClaudeFiles(args: {
rootDirs: readonly string[]
scopePaths: readonly string[]
limit: number
excludedFilePaths: ReadonlySet<string>
issues: AiVaultScanIssue[]
}): Promise<FileWithMtime[]> {
if (args.scopePaths.length === 0 || args.limit <= 0) {
return []
}
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await readProjectDirCwd(projectDir)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
await collectClaudeFiles({
projectDir,
issues: args.issues,
collected,
limit: args.limit,
excludedFilePaths: args.excludedFilePaths
})
}
}
return [...collected.values()].sort((left, right) => right.mtimeMs - left.mtimeMs)
}
function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string> {
const prefixes = new Set<string>()
for (const scopePath of scopePaths) {
for (const candidate of scopePathCandidates(scopePath)) {
prefixes.add(encodeClaudeProjectPath(candidate))
}
}
return prefixes
}
function scopePathCandidates(scopePath: string): string[] {
const wslScopePath = parseWslUncPath(scopePath)
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
}
function encodeClaudeProjectPath(pathValue: string): string {
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
}
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
for (const prefix of scopePrefixes) {
if (projectDirName === prefix || projectDirName.startsWith(`${prefix}-`)) {
return true
}
}
return false
}
function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
if (isPathInsideOrEqual(scopePath, cwd)) {
return true
}
const wslScopePath = parseWslUncPath(scopePath)
if (!wslScopePath) {
return false
}
// WSL transcripts record Linux cwd values even when the renderer sends the
// active worktree as a Windows UNC path.
return isPathInsideOrEqual(wslScopePath.linuxPath, cwd)
}
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
return []
}
return entries
.filter(
(entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)
)
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
if (cwd) {
return cwd
}
}
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
return []
}
const newest: { path: string; mtimeMs: number }[] = []
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(projectDir, entry.name)
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
})
} catch {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
for await (const line of lines) {
if (read++ >= REPRESENTATIVE_CWD_LINE_LIMIT) {
break
}
const cwd = extractString(parseJsonObject(line)?.cwd)
if (cwd) {
return cwd
}
}
} catch {
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
// break/catch paths don't leak a file descriptor (this runs per project dir).
lines.close()
input.destroy()
}
return null
}
async function collectClaudeFiles(args: {
projectDir: string
issues: AiVaultScanIssue[]
collected: Map<string, FileWithMtime>
limit: number
excludedFilePaths: ReadonlySet<string>
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(args.projectDir, entry.name)
if (args.collected.has(path) || args.excludedFilePaths.has(path)) {
continue
}
try {
const fileStat = await stat(path)
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
}
}
}
function addBoundedFile(
collected: Map<string, FileWithMtime>,
limit: number,
file: FileWithMtime
): void {
if (collected.size < limit) {
collected.set(file.path, file)
return
}
let oldest: FileWithMtime | null = null
for (const candidate of collected.values()) {
if (!oldest || candidate.mtimeMs < oldest.mtimeMs) {
oldest = candidate
}
}
if (oldest && file.mtimeMs > oldest.mtimeMs) {
collected.delete(oldest.path)
collected.set(file.path, file)
}
}
function addBoundedPath<T extends { mtimeMs: number }>(items: T[], limit: number, item: T): void {
if (items.length < limit) {
items.push(item)
return
}
let oldestIndex = 0
for (let index = 1; index < items.length; index++) {
if (items[index].mtimeMs < items[oldestIndex].mtimeMs) {
oldestIndex = index
}
}
if (item.mtimeMs > items[oldestIndex].mtimeMs) {
items[oldestIndex] = item
}
}
@@ -0,0 +1,169 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import type { AiVaultScanOptions } from './session-scanner-types'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Point every non-Claude source at a nonexistent dir so the scan only sees the
// Claude fixtures created per test.
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
return {
claudeProjectsDir,
codexSessionsDir: '/nonexistent/codex',
geminiSessionsDir: '/nonexistent/gemini',
copilotSessionsDir: '/nonexistent/copilot',
cursorProjectsDir: '/nonexistent/cursor',
opencodeStorageDir: '/nonexistent/opencode',
opencodeDbPaths: [],
grokSessionsDir: '/nonexistent/grok',
devinTranscriptsDir: '/nonexistent/devin',
hermesSessionsDir: '/nonexistent/hermes',
rovoSessionsDir: '/nonexistent/rovo',
openclawStateDir: '/nonexistent/openclaw',
openclawLegacyStateDir: '/nonexistent/openclaw-legacy',
piSessionsDir: '/nonexistent/pi',
droidSessionsDir: '/nonexistent/droid',
droidProjectsDir: '/nonexistent/droid-projects',
kimiSessionsDir: '/nonexistent/kimi',
...extra
} satisfies AiVaultScanOptions
}
async function writeClaudeSession(args: {
claudeRoot: string
dirName: string
sessionId: string
cwd: string
iso: string
leadingCwdlessLine?: boolean
}): Promise<void> {
const dir = join(args.claudeRoot, args.dirName)
await mkdir(dir, { recursive: true })
const records: unknown[] = []
if (args.leadingCwdlessLine) {
records.push({ type: 'last-prompt', sessionId: args.sessionId })
}
records.push({
type: 'user',
sessionId: args.sessionId,
timestamp: args.iso,
cwd: args.cwd,
message: { role: 'user', content: `work in ${args.cwd}` }
})
const filePath = join(dir, `${args.sessionId}.jsonl`)
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
const time = new Date(args.iso)
await utimes(filePath, time, time)
}
describe('scanAiVaultSessions scope inclusion', () => {
it('surfaces in-scope sessions older than the global recency cap', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
// One old in-scope session that the recency cap would otherwise drop.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'old-in-scope',
cwd: '/repo/app',
iso: '2026-01-01T00:00:00.000Z',
leadingCwdlessLine: true
})
// A session in a sub-cwd directory of the same workspace path.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app-packages-ui',
sessionId: 'old-in-scope-subdir',
cwd: '/repo/app/packages/ui',
iso: '2026-01-02T00:00:00.000Z'
})
// Recent out-of-scope sessions that fill the cap.
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-${index}`,
sessionId: `recent-${index}`,
cwd: `/other/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
const withScope = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
)
const ids = (result: { sessions: { sessionId: string }[] }) =>
result.sessions.map((session) => session.sessionId)
// The cap hides the old in-scope sessions when no scope is provided.
expect(ids(withoutScope)).not.toContain('old-in-scope')
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
// Scope paths force them back in, including the sub-cwd directory.
expect(ids(withScope)).toContain('old-in-scope')
expect(ids(withScope)).toContain('old-in-scope-subdir')
})
it('does not duplicate sessions already in the capped result', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'recent-in-scope',
cwd: '/repo/app',
iso: '2026-06-24T00:00:00.000Z'
})
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 50, scopePaths: ['/repo/app'] })
)
const matches = result.sessions.filter((session) => session.sessionId === 'recent-in-scope')
expect(matches).toHaveLength(1)
})
it('matches WSL UNC scope paths against Linux Claude cwd values', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-home-ada-repo',
sessionId: 'old-wsl-in-scope',
cwd: '/home/ada/repo',
iso: '2026-01-01T00:00:00.000Z'
})
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-wsl-${index}`,
sessionId: `recent-wsl-${index}`,
cwd: `/other/wsl/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, {
limit: 2,
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
})
)
expect(result.sessions.map((session) => session.sessionId)).toContain('old-wsl-in-scope')
})
})
@@ -29,6 +29,9 @@ export type AiVaultScanOptions = {
kimiSessionsDir?: string
limit?: number
limitPerAgent?: number
// Active workspace/project paths whose sessions must be included regardless of
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
}
+70 -2
View File
@@ -6,6 +6,7 @@ import type {
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
@@ -13,6 +14,7 @@ import {
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery,
SessionParseResult
} from './session-scanner-types'
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
@@ -20,6 +22,9 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
// Upper bound on extra in-scope transcripts discovered and parsed past the
// recency cap; guards against a pathological scoped history directory.
const SCOPE_PARSE_LIMIT = 2000
/**
* Scan all supported AI agent session stores and return a unified, sorted,
@@ -61,17 +66,80 @@ export async function scanAiVaultSessions(
issues
})
const sessions = parsedSessions
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
issues
})
return {
sessions,
sessions: mergeSessions(cappedSessions, scopeSessions),
issues,
scannedAt: new Date().toISOString()
}
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
// (already capped) result and the scope result are unioned and de-duplicated by
// session id, then re-sorted DESC.
function mergeSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
async function scanInScopeSessions(args: {
discoveries: SessionFileDiscovery[]
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const claudeRootDirs = args.discoveries
.filter((discovery) => discovery.agent === 'claude')
.map((discovery) => discovery.rootDir)
const files = await discoverInScopeClaudeFiles({
rootDirs: claudeRootDirs,
scopePaths: args.scopePaths,
limit: SCOPE_PARSE_LIMIT,
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
const candidates = files.map(
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
)
if (candidates.length === 0) {
return []
}
// Parse every in-scope candidate (limit === candidate count never early-stops).
return parseSessionCandidates({
candidates,
limit: candidates.length,
platform: args.platform,
issues: args.issues
})
}
async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
@@ -87,6 +87,7 @@ describe('AntigravityHookService', () => {
)
expect(script).toContain('/hook/antigravity')
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')
expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT')
expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }')
expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }')
@@ -174,6 +175,7 @@ describe('AntigravityHookService', () => {
'utf8'
)
expect(script).toContain('/hook/antigravity')
expect(script).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')
expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT')
expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }')
expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }')
+3 -2
View File
@@ -166,8 +166,9 @@ function getWindowsWrapperScript(eventName: string): string {
function buildWindowsAntigravityHookPostCommand(): string {
// Why: Antigravity hooks are best-effort status updates; do not let a stalled
// local listener hold the agent process open.
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
// local listener hold the agent process open. Qualify PowerShell so a
// worktree-local powershell.exe cannot hijack hook payloads.
return `"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -11,6 +11,8 @@ export type HeadlessAutomationDispatchLaunch = {
workspaceId: string
workspaceDisplayName?: string | null
terminalSessionId: string | null
terminalPaneKey?: string | null
terminalPtyId?: string | null
completion?: Promise<{
status: 'completed' | 'dispatch_failed'
outputSnapshot?: AutomationRunOutputSnapshot | null
+6
View File
@@ -317,6 +317,8 @@ describe('AutomationService', () => {
workspaceId: 'remote-wt-1',
workspaceDisplayName: 'Remote automation',
terminalSessionId: 'remote-tab-1',
terminalPaneKey: 'remote-tab-1:11111111-1111-4111-8111-111111111111',
terminalPtyId: 'remote-pty-1',
completion: Promise.resolve({
status: 'completed',
outputSnapshot: {
@@ -336,11 +338,15 @@ describe('AutomationService', () => {
expect(run.workspaceId).toBe('remote-wt-1')
expect(run.workspaceDisplayName).toBe('Remote automation')
expect(run.terminalSessionId).toBe('remote-tab-1')
expect(run.terminalPaneKey).toBe('remote-tab-1:11111111-1111-4111-8111-111111111111')
expect(run.terminalPtyId).toBe('remote-pty-1')
await vi.waitFor(() =>
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'completed',
workspaceId: 'remote-wt-1',
terminalSessionId: 'remote-tab-1',
terminalPaneKey: 'remote-tab-1:11111111-1111-4111-8111-111111111111',
terminalPtyId: 'remote-pty-1',
outputSnapshot: expect.objectContaining({ content: 'Done.' })
})
)
+10 -9
View File
@@ -262,12 +262,17 @@ export class AutomationService {
}
try {
const launch = await this.headlessDispatcher!({ automation, run, target })
const updated = this.store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
const launchRunTarget = {
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = this.store.updateAutomationRun({
runId: run.id,
status: 'dispatched',
...launchRunTarget,
error: null
})
if (launch.completion) {
@@ -276,9 +281,7 @@ export class AutomationService {
this.markDispatchResult({
runId: run.id,
status: completion.status,
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
...launchRunTarget,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
@@ -288,9 +291,7 @@ export class AutomationService {
this.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
...launchRunTarget,
error: error instanceof Error ? error.message : String(error)
})
)
+8 -8
View File
@@ -469,7 +469,7 @@ describe('setupGuestShortcutForwarding', () => {
}
})
it('forwards browser page zoom shortcuts from focused guest pages', () => {
it('forwards app zoom shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
@@ -497,12 +497,12 @@ describe('setupGuestShortcutForwarding', () => {
expect(numpadSubtractPreventDefault).toHaveBeenCalledTimes(1)
expect(resetPreventDefault).toHaveBeenCalledTimes(1)
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:zoomBrowserPage', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:zoomBrowserPage', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:zoomBrowserPage', 'reset')
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'terminal:zoom', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'terminal:zoom', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'terminal:zoom', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'terminal:zoom', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'terminal:zoom', 'reset')
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'terminal:zoom', 'in')
})
it('forwards browser history shortcuts from focused guest pages', () => {
@@ -558,7 +558,7 @@ describe('setupGuestShortcutForwarding', () => {
expect(defaultPreventDefault).not.toHaveBeenCalled()
expect(customPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenCalledWith('terminal:zoom', 'in')
})
it('forwards double-tap window shortcuts from focused guest pages', () => {
+3 -3
View File
@@ -279,11 +279,11 @@ export function setupGuestShortcutForwarding(args: {
): boolean => {
const keybindings = getKeybindings?.()
if (action?.type === 'zoom') {
// Why: browser page zoom must consume repeats and teardown races before
// Chromium or the guest page can apply its own shortcut behavior.
// Why: keyboard zoom is Orca chrome zoom. Focused guests bypass the
// main-window shortcut path, so forward to the shared renderer zoom router.
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:zoomBrowserPage', action.direction)
renderer?.send('terminal:zoom', action.direction)
return true
}
if (input.isAutoRepeat) {
@@ -1,6 +1,7 @@
import { randomUUID } from 'crypto'
import { BrowserWindow } from 'electron'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend'
import type { BrowserManager } from './browser-manager'
import { browserSessionRegistry } from './browser-session-registry'
@@ -35,6 +36,9 @@ export class OffscreenBrowserBackend implements BrowserBackend {
width: DEFAULT_VIEWPORT_WIDTH,
height: DEFAULT_VIEWPORT_HEIGHT,
webPreferences: {
// Why: offscreen pages are the SSH/headless browser backend; keep their
// HTML fullscreen behavior aligned with desktop <webview> guests.
...ORCA_BROWSER_GUEST_WEB_PREFERENCES,
partition,
sandbox: true,
contextIsolation: true,
@@ -0,0 +1,27 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
const OFFSCREEN_BACKEND_SOURCE = resolve(__dirname, 'offscreen-browser-backend.ts')
function sourceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start)
const endIndex = source.indexOf(end, startIndex + start.length)
expect(startIndex).toBeGreaterThanOrEqual(0)
expect(endIndex).toBeGreaterThan(startIndex)
return source.slice(startIndex, endIndex)
}
describe('OffscreenBrowserBackend web preferences', () => {
it('uses the shared browser guest fullscreen policy', () => {
const source = readFileSync(OFFSCREEN_BACKEND_SOURCE, 'utf8')
const webPreferencesBlock = sourceBetween(source, 'webPreferences: {', 'partition,')
expect(source).toContain(
"import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'"
)
expect(webPreferencesBlock).toContain('...ORCA_BROWSER_GUEST_WEB_PREFERENCES')
})
})
+32 -8
View File
@@ -21,6 +21,8 @@ import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
const OPENCLAUDE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
type FakeFs = {
files: Map<string, string>
@@ -163,7 +165,7 @@ describe('ClaudeHookService.install', () => {
expect(
legacyCommands.some((command: string) =>
process.platform === 'win32'
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
? WINDOWS_POWERSHELL_LAUNCHER.test(command)
: command.includes(CLAUDE_SCRIPT_FILE_NAME)
)
).toBe(true)
@@ -174,7 +176,7 @@ describe('ClaudeHookService.install', () => {
).toBe(false)
expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
? WINDOWS_POWERSHELL_LAUNCHER
: new RegExp(CLAUDE_SCRIPT_FILE_NAME)
)
expect(
@@ -205,9 +207,7 @@ describe('ClaudeHookService.install', () => {
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
vi.unstubAllEnvs()
@@ -215,6 +215,32 @@ describe('ClaudeHookService.install', () => {
}
}
)
// Why: the launcher must stay PowerShell-encoded for Git Bash, but the hook
// POST inside the .cmd should use curl.exe so each hook spawns one
// interpreter, not two. Posting via a second PowerShell was the slow path.
it.skipIf(process.platform !== 'win32')(
'posts from the managed .cmd via curl.exe, not a second PowerShell',
() => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-curl-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
expect(new ClaudeHookService().install().state).toBe('installed')
const script = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
'utf-8'
)
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).toContain('/hook/claude')
expect(script).not.toMatch(/Invoke-WebRequest/i)
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
}
)
})
describe('ClaudeHookService.installRemote', () => {
@@ -326,9 +352,7 @@ describe('OpenClaudeHookService-compatible install', () => {
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = parsed.hooks[event][0].hooks[0].command as string
if (process.platform === 'win32') {
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
} else {
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
expect(command).toMatch(/^if \[ -x /)
+7 -2
View File
@@ -1,7 +1,7 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookPostCommand,
buildWindowsAgentHookCurlPostCommand,
readHooksJson,
writeHooksJson,
writeManagedScript
@@ -63,7 +63,12 @@ function getManagedScript(
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
buildWindowsAgentHookPostCommand('claude'),
// Why: post via curl.exe, not a second PowerShell. Claude's launcher is
// already an encoded PowerShell command (Git Bash needs it to survive
// spaces); a PowerShell post on top of that meant two interpreter
// startups per hook. The post runs inside the .cmd (cmd.exe context), so
// curl works the same here as for the POSIX/Codex hooks.
buildWindowsAgentHookCurlPostCommand('claude'),
'exit /b 0',
''
].join('\r\n')
@@ -144,6 +144,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+1
View File
@@ -148,6 +148,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+230 -20
View File
@@ -16,6 +16,7 @@ import {
computeTrustKey,
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -246,6 +247,29 @@ describe('computeTrustKey', () => {
})
).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`)
})
it('uses native Windows backslashes in the trust key Codex looks up', () => {
// Why: Codex 0.140 writes approved Windows hook trust keys as raw native
// paths under [hooks.state].
const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const key = computeTrustKey({
sourcePath: winPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
})
expect(key).toContain('\\')
expect(key.startsWith('C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json:')).toBe(true)
})
it('preserves literal backslashes in non-Windows-style fallback paths', () => {
// Why: SSH/POSIX paths can legally contain `\` as a filename character;
// only Windows-style separators should be normalized.
expect(getCodexCanonicalTrustPath('/tmp/with\\literal/hooks.json')).toBe(
'/tmp/with\\literal/hooks.json'
)
})
})
describe('upsertHookTrustEntries', () => {
@@ -344,10 +368,11 @@ describe('upsertHookTrustEntries', () => {
})
it('collapses duplicate blocks for the same hook key while preserving unrelated hook state', () => {
const key =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0'
const unrelatedKey =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:stop:0:0'
const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const key = `${sourcePath}:session_start:0:0`
const unrelatedSourcePath =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const unrelatedKey = `${unrelatedSourcePath}:stop:0:0`
const original = [
`[hooks.state."${escapeTomlString(key)}"]`,
'enabled = true',
@@ -365,7 +390,7 @@ describe('upsertHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json',
sourcePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
@@ -375,9 +400,10 @@ describe('upsertHookTrustEntries', () => {
const written = readFileSync(configPath, 'utf-8')
const duplicateKeyOccurrences = written.match(
new RegExp(`\\[hooks\\.state\\."${escapeRegex(escapeTomlString(key))}"\\]`, 'g')
new RegExp(`\\[hooks\\.state\\.'${escapeRegex(key)}'\\]`, 'g')
)
expect(duplicateKeyOccurrences).toHaveLength(1)
// The unrelated key was not upserted and stays in its original escaped form.
expect(written).toContain(`[hooks.state."${escapeTomlString(unrelatedKey)}"]`)
expect(written).toContain('trusted_hash = "sha256:KEEP"')
expect(written).toContain('enabled = false')
@@ -386,7 +412,7 @@ describe('upsertHookTrustEntries', () => {
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('collapses a literal-string hook table before writing the canonical basic-string table', () => {
it('collapses a literal-string hook table before writing the canonical Codex literal table', () => {
const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const key = `${sourcePath}:session_start:0:0`
const original = [
@@ -407,9 +433,9 @@ describe('upsertHookTrustEntries', () => {
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state.'${key}']`)
expect(written.match(/\[hooks\.state\./g)).toHaveLength(1)
expect(written).toContain(`[hooks.state."${escapeTomlString(key)}"]`)
expect(written.match(/\[hooks\.state\./g)).toHaveLength(2)
expect(written).toContain(`[hooks.state.'${key}']`)
expect(written).toContain(`[hooks.state.'${key.replace(/\\/g, '/')}']`)
expect(written).toContain('enabled = false')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
@@ -710,7 +736,9 @@ describe('upsertHookTrustEntries', () => {
expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]')
})
it('escapes literal `"` and `\\` in the source path inside the trust block header', () => {
it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => {
// Why: a backslash in a POSIX path can be a literal filename character, so
// it must still be escaped instead of normalized away.
const entry: CodexTrustEntry = {
sourcePath: '/x/with"quote\\and\\back/hooks.json',
eventLabel: 'pre_tool_use',
@@ -821,6 +849,111 @@ describe('upsertHookTrustEntries', () => {
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
it('finds and replaces a legacy forward-slash block when Orca upserts with native backslash key', () => {
// Why: Codex 0.140 can expose Windows trust keys with either separator
// shape depending on startup cwd, so Orca replaces stale blocks with both.
const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const legacyKey = `${backslashPath.replace(/\\/g, '/')}:session_start:0:0`
const original = [
`[hooks.state."${legacyKey}"]`,
'enabled = true',
'trusted_hash = "sha256:CODEX-WRITTEN"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: backslashPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2)
expect(written).toContain(`[hooks.state.'${backslashPath}:session_start:0:0']`)
expect(written).toContain(`[hooks.state.'${legacyKey}']`)
expect(written).not.toContain('sha256:CODEX-WRITTEN')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('produces exactly one Windows separator pair after two consecutive upserts', () => {
// Why: idempotency guard — repeated auto-install on app start must not
// accumulate duplicate trust blocks and produce invalid TOML.
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json',
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2)
expect((written.match(/session_start:0:0/g) ?? []).length).toBe(2)
})
it('falls back to TOML basic-string headers when a Windows path contains an apostrophe', () => {
// Why: TOML literal-string table keys cannot contain apostrophes, but
// Windows user/profile paths can.
const entry: CodexTrustEntry = {
sourcePath: "C:\\Users\\O'Connor\\AppData\\Roaming\\orca\\hooks.json",
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\."/g) ?? []).length).toBe(2)
expect(written).toContain(
`[hooks.state."C:\\\\Users\\\\O'Connor\\\\AppData\\\\Roaming\\\\orca\\\\hooks.json:session_start:0:0"]`
)
expect(written).toContain(
`[hooks.state."C:/Users/O'Connor/AppData/Roaming/orca/hooks.json:session_start:0:0"]`
)
expect(written).not.toContain(`[hooks.state.'C:\\Users\\O'Connor`)
})
it.skipIf(process.platform !== 'win32')(
'finds a Codex-written block with lowercased username when Orca key has mixed-case username',
() => {
// Why: realpathSync.native casing can differ between what Codex wrote
// (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...).
// normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is
// replaced rather than a duplicate appended.
const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json'
const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${lowercasePath}:session_start:0:0`
const original = [
`[hooks.state.'${literalKey}']`,
'enabled = true',
'trusted_hash = "sha256:LOWERCASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: mixedCasePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2)
expect(written).not.toContain('sha256:LOWERCASE')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
}
)
})
describe('upsertProjectTrustLevel', () => {
@@ -837,7 +970,7 @@ describe('upsertProjectTrustLevel', () => {
mkdirSync(projectDir)
const aliasedProjectPath = join(nestedDir, '..', 'project')
const trustedPath = realpathSync.native(aliasedProjectPath)
const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
const trustedTomlPath = escapeTomlString(trustedPath)
expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe(
[`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n')
@@ -884,7 +1017,9 @@ describe('upsertProjectTrustLevel', () => {
expect(updated).toContain('[other]\nvalue = 1')
})
it('preserves CRLF endings and escapes the project path in the header', () => {
it('preserves CRLF endings and writes native Windows path separators in the header', () => {
// Why: local project trust still follows Codex's realpath display, while
// remote project trust preserves the SSH provider's canonical path string.
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
@@ -895,6 +1030,56 @@ describe('upsertProjectTrustLevel', () => {
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
})
it('updates an existing Windows backslash project block after separator normalization', () => {
// Why: hook trust now writes paired Windows variants, but project trust
// must still repair an existing single project table in place.
const original = [
'[projects."C:\\\\Users\\\\nw\\\\repo"]',
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated.match(/\[projects\./g)).toHaveLength(1)
expect(updated).toContain('[projects."C:\\\\Users\\\\nw\\\\repo"]')
expect(updated).toContain('notes = "keep"')
expect(updated).toContain('trust_level = "trusted"')
expect(updated).not.toContain('trust_level = "untrusted"')
})
it('updates an existing legacy Windows forward-slash project block', () => {
// Why: older Orca builds normalized Windows project paths to forward
// slashes; native-backslash hook fixes must not duplicate those blocks.
const original = [
'[projects."C:/Users/nw/repo"]',
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated.match(/\[projects\./g)).toHaveLength(1)
expect(updated).toContain('[projects."C:/Users/nw/repo"]')
expect(updated).toContain('notes = "keep"')
expect(updated).toContain('trust_level = "trusted"')
expect(updated).not.toContain('trust_level = "untrusted"')
})
it('preserves an already-canonical remote Windows project path', () => {
// Why: SSH project paths are resolved on the remote; local realpath would
// canonicalize the wrong machine if the same path happens to exist locally.
const updated = upsertProjectTrustLevelInContent('', 'C:/Users/nw/repo', 'trusted', {
alreadyCanonical: true
})
expect(updated).toBe(
['[projects."C:/Users/nw/repo"]', 'trust_level = "trusted"', ''].join('\n')
)
})
it('writes config.toml and avoids rewriting an already-trusted project', () => {
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
const firstWrite = readFileSync(configPath, 'utf-8')
@@ -1181,10 +1366,11 @@ describe('readHookTrustEntries', () => {
expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true })
})
it('unescapes `\\\\` in the block key', () => {
it('normalizes backslash block key to forward-slash at ingestion', () => {
// Why: a real Windows path on disk like C:\foo gets written escaped as
// `C:\\foo` inside the TOML key — the returned Map should expose the
// original unescaped form.
// `C:\\foo` inside the TOML key. The Map key is normalized (backslash ->
// forward-slash) so computeTrustKey lookups match regardless of how Codex
// encoded the path separator.
const original = [
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
'enabled = true',
@@ -1194,13 +1380,13 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
expect(result.get('C:/foo/hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
})
it('reads a literal-string hook table key', () => {
const key = 'C:\\foo\\hooks.json:session_start:0:0'
const rawKey = 'C:\\foo\\hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${key}']`,
`[hooks.state.'${rawKey}']`,
'enabled = false',
'trusted_hash = "sha256:LITERAL"',
''
@@ -1208,9 +1394,33 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(key)).toEqual({ trustedHash: 'sha256:LITERAL', enabled: false })
expect(result.get('C:/foo/hooks.json:session_start:0:0')).toEqual({
trustedHash: 'sha256:LITERAL',
enabled: false
})
})
it.skipIf(process.platform !== 'win32')(
'supports case-insensitive lookups for Windows hook trust keys read from config',
() => {
// Why: Codex and realpathSync.native can disagree on user-path casing;
// status checks still need Map.get(computeTrustKey(...)) to find the row.
const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0'
const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${rawKey}']`,
'enabled = true',
'trusted_hash = "sha256:CASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true })
}
)
it('reads entries from a CRLF-terminated config', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
+159 -17
View File
@@ -52,6 +52,26 @@ export type CodexHookTrustState = {
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
// Why: callers use computeTrustKey() for lookups, while existing TOML can carry
// Codex-written separator/casing variants. Normalize both sides at the Map edge.
class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
override get(key: string): CodexHookTrustState | undefined {
return super.get(normalizeHookTrustKeyForLookup(key))
}
override has(key: string): boolean {
return super.has(normalizeHookTrustKeyForLookup(key))
}
override delete(key: string): boolean {
return super.delete(normalizeHookTrustKeyForLookup(key))
}
override set(key: string, value: CodexHookTrustState): this {
return super.set(normalizeHookTrustKeyForLookup(key), value)
}
}
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
function canonicalize(value: unknown): unknown {
@@ -102,11 +122,40 @@ export function getCodexCanonicalTrustPath(sourcePath: string): string {
try {
// Why: Codex canonicalizes trust paths before building config keys. On
// macOS, /var is a symlink to /private/var; trusting the raw path still
// leaves the TUI in review/trust prompts.
// leaves the TUI in review/trust prompts. On Windows, Codex 0.140 writes
// hook state keys with native raw backslashes under an explicit parent table.
return realpathSync.native(sourcePath)
} catch {
return normalizeWindowsPathForCodexLookup(sourcePath)
}
}
function getCodexCanonicalProjectPath(projectPath: string): string {
try {
// Why: local project trust still needs Codex's realpath shape, but remote
// SSH callers pass an already-canonical remote path string.
return realpathSync.native(projectPath)
} catch {
return projectPath
}
}
function normalizeWindowsPathForCodexLookup(sourcePath: string): string {
if (!usesWindowsPathSeparators(sourcePath)) {
return sourcePath
}
return sourcePath.replace(/\//g, '\\')
}
function normalizeWindowsPathSeparators(sourcePath: string): string {
if (!usesWindowsPathSeparators(sourcePath)) {
return sourcePath
}
return sourcePath.replace(/\\/g, '/')
}
function usesWindowsPathSeparators(sourcePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\')
}
export function parseTrustKey(key: string): {
@@ -210,9 +259,17 @@ export function upsertHookTrustEntriesInContent(
): string {
const existing =
existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
let updated = existing
let updated = entries.some((entry) =>
usesWindowsPathSeparators(getCodexCanonicalTrustPath(entry.sourcePath))
)
? ensureHooksStateParentTable(existing)
: existing
for (const entry of entries) {
updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
updated = upsertTrustBlocks(
updated,
getTrustKeyWriteVariants(computeTrustKey(entry)),
computeTrustedHash(entry)
)
}
return updated
}
@@ -233,11 +290,14 @@ export function upsertProjectTrustLevel(
export function upsertProjectTrustLevelInContent(
existingContent: string,
projectPath: string,
trustLevel: CodexProjectTrustLevel
trustLevel: CodexProjectTrustLevel,
options?: { alreadyCanonical?: boolean }
): string {
const existing =
existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
const trustedProjectPath = getCodexCanonicalTrustPath(projectPath)
const trustedProjectPath = options?.alreadyCanonical
? projectPath
: getCodexCanonicalProjectPath(projectPath)
const headerPattern = buildProjectHeaderPattern(trustedProjectPath)
const match = headerPattern.exec(existing)
const eol = existing.includes('\r\n') ? '\r\n' : '\n'
@@ -279,12 +339,34 @@ export function upsertProjectTrustLevelInContent(
// `enabled = false` survives reinstall.
function buildTrustBlock(key: string, hash: string, enabled: boolean): string {
return [
`[hooks.state."${escapeTomlString(key)}"]`,
`[hooks.state.${formatHookStateTableKey(key)}]`,
`enabled = ${enabled}`,
`trusted_hash = "${escapeTomlString(hash)}"`
].join('\n')
}
function formatHookStateTableKey(key: string): string {
const parsed = parseTrustKey(key)
if (parsed && usesWindowsPathSeparators(parsed.sourcePath) && !key.includes("'")) {
// Why: Codex 0.140 trusts Windows hooks only when the state table keys
// match the raw native path shape it writes after /hooks approval.
return `'${key}'`
}
return `"${escapeTomlString(key)}"`
}
function getTrustKeyWriteVariants(key: string): string[] {
const parsed = parseTrustKey(key)
if (!parsed || !usesWindowsPathSeparators(parsed.sourcePath)) {
return [key]
}
const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
return [
`${parsed.sourcePath.replace(/\//g, '\\')}${suffix}`,
`${parsed.sourcePath.replace(/\\/g, '/')}${suffix}`
].filter((variant, index, variants) => variants.indexOf(variant) === index)
}
// Why: TOML basic strings forbid raw control chars; escape backslash first so
// later substitutions don't double-escape the inserted backslashes.
export function escapeTomlString(value: string): string {
@@ -298,10 +380,18 @@ export function escapeTomlString(value: string): string {
.replaceAll('\t', '\\t')
}
function upsertTrustBlock(content: string, key: string, hash: string): string {
const ranges = findTrustBlockRanges(content, key)
function upsertTrustBlocks(content: string, keys: readonly string[], hash: string): string {
const ranges = keys
.flatMap((key) => findTrustBlockRanges(content, key))
.filter(
(range, index, ranges) =>
ranges.findIndex(
(candidate) => candidate.start === range.start && candidate.end === range.end
) === index
)
.sort((a, b) => a.start - b.start)
if (ranges.length === 0) {
const block = buildTrustBlock(key, hash, true)
const block = buildTrustBlocks(keys, hash, true)
if (content.length === 0) {
return `${block}\n`
}
@@ -323,7 +413,7 @@ function upsertTrustBlock(content: string, key: string, hash: string): string {
)
return enabledMatch?.[1] === 'false'
})
const block = buildTrustBlock(key, hash, enabled)
const block = buildTrustBlocks(keys, hash, enabled)
let cursor = 0
let deduped = ''
ranges.forEach((range, index) => {
@@ -336,12 +426,46 @@ function upsertTrustBlock(content: string, key: string, hash: string): string {
return deduped + content.slice(cursor)
}
function buildTrustBlocks(keys: readonly string[], hash: string, enabled: boolean): string {
// Why: Codex 0.140 can expose Windows hook-state keys with either native
// backslashes or forward slashes depending on the cwd string used at startup.
return keys.map((key) => buildTrustBlock(key, hash, enabled)).join('\n\n')
}
function ensureHooksStateParentTable(content: string): string {
if (/^[ \t]*\[hooks\.state\][ \t]*(?:#[^\r\n]*)?$/m.test(content)) {
return content
}
const eol = content.includes('\r\n') ? '\r\n' : '\n'
const parent = `[hooks.state]${eol}`
const hookHeader = /^[ \t]*\[hooks\.state\.(?:"|')/m.exec(content)
if (hookHeader) {
return `${content.slice(0, hookHeader.index)}${parent}${eol}${content.slice(hookHeader.index)}`
}
if (content.length === 0) {
return parent
}
const separator = content.endsWith(`${eol}${eol}`) ? '' : content.endsWith(eol) ? eol : eol + eol
return `${content}${separator}${parent}`
}
type TrustBlockRange = {
start: number
headerLineEnd: number
end: number
}
// Why: separator and casing drift between Codex-written keys (raw backslash,
// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native
// casing) must not prevent findTrustBlockRanges from matching an existing block.
export function normalizeHookTrustKeyForLookup(key: string): string {
const parsed = parseTrustKey(key)
const separated = parsed
? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
: normalizeWindowsPathSeparators(key)
return process.platform === 'win32' ? separated.toLowerCase() : separated
}
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const ranges: TrustBlockRange[] = []
let cursor = 0
@@ -355,7 +479,10 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const headerKey = isInsideTomlMultilineString(multilineState)
? null
: parseHookStateHeaderKey(line)
if (headerKey === key) {
if (
headerKey !== null &&
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
) {
const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd
const after = content.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
@@ -371,9 +498,16 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
}
function buildProjectHeaderPattern(projectPath: string): RegExp {
const escapedPath = escapeRegex(escapeTomlString(projectPath))
const headerPathValues = [projectPath]
if (usesWindowsPathSeparators(projectPath)) {
headerPathValues.push(projectPath.replace(/\//g, '\\'), projectPath.replace(/\\/g, '/'))
}
const headerPaths = [...new Set(headerPathValues)].map((path) =>
escapeRegex(escapeTomlString(path))
)
return new RegExp(
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
`(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`,
process.platform === 'win32' ? 'i' : undefined
)
}
@@ -681,7 +815,7 @@ function removeTrustBlock(content: string, key: string): string {
}
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
const result = new Map<string, CodexHookTrustState>()
const result = new HookTrustEntryMap()
if (!existsSync(configPath)) {
return result
}
@@ -707,9 +841,17 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
// a line scan beats pulling in a full TOML value parser.
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
result.set(key, {
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined,
enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined
const normalizedKey = normalizeHookTrustKeyForLookup(key)
const existingState = result.get(normalizedKey)
const enabled = enabledMatch ? enabledMatch[1] === 'true' : undefined
result.set(normalizedKey, {
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : existingState?.trustedHash,
// Why: Windows writes both slash variants for one hook; a disabled copy
// must remain authoritative regardless of which variant appears last.
enabled:
existingState?.enabled === false || enabled === false
? false
: (enabled ?? existingState?.enabled)
})
cursor = nextCursor
continue
+319 -63
View File
@@ -10,9 +10,12 @@ import {
symlinkSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import { homedir, tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
import { spawn } from 'child_process'
import { createServer } from 'http'
import type { AddressInfo } from 'net'
import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils'
import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust'
@@ -37,6 +40,9 @@ vi.mock('os', async (importOriginal) => {
import { CodexHookService } from './hook-service'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
let tmpHome: string
let userDataDir: string
@@ -76,7 +82,10 @@ function escapeTomlBasicString(value: string): string {
}
function hookTrustHeader(key: string): string {
return `[hooks.state."${escapeTomlBasicString(canonicalizeHookTrustKeyForTest(key))}"]`
const canonicalKey = canonicalizeHookTrustKeyForTest(key)
return /^[A-Za-z]:[\\/]|^\\\\/.test(canonicalKey) && !canonicalKey.includes("'")
? `[hooks.state.'${canonicalKey}']`
: `[hooks.state."${escapeTomlBasicString(canonicalKey)}"]`
}
function canonicalizeHookTrustKeyForTest(key: string): string {
@@ -88,12 +97,37 @@ function canonicalizeHookTrustKeyForTest(key: string): string {
}
const sourcePath = key.slice(0, thirdLast)
try {
// Why: mirrors getCodexCanonicalTrustPath so test expectations match the
// native raw-backslash keys Codex writes after hook approval on Windows.
return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}`
} catch {
return key
return /^[A-Za-z]:[\\/]|^\\\\/.test(sourcePath)
? `${sourcePath.replace(/\//g, '\\')}${key.slice(thirdLast)}`
: key
}
}
function markHookTrustDisabled(toml: string, header: string): string {
const headerIndex = toml.indexOf(header)
expect(headerIndex).not.toBe(-1)
const nextHeaderIndex = toml.indexOf('\n[', headerIndex + header.length)
const blockEnd = nextHeaderIndex === -1 ? toml.length : nextHeaderIndex
const block = toml.slice(headerIndex, blockEnd)
expect(block).toContain('enabled = true')
return `${toml.slice(0, headerIndex)}${block.replace('enabled = true', 'enabled = false')}${toml.slice(blockEnd)}`
}
function localManagedCodexEvents(): string[] {
return [
'PermissionRequest',
'PostToolUse',
'PreToolUse',
'SessionStart',
'Stop',
'UserPromptSubmit'
]
}
describe('CodexHookService', () => {
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => {
const systemCodexHome = join(tmpHome, '.codex')
@@ -113,16 +147,7 @@ describe('CodexHookService', () => {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
expect(Object.keys(hooksConfig.hooks).sort()).toEqual(
[
'PermissionRequest',
'PostToolUse',
'PreToolUse',
'SessionStart',
'Stop',
'UserPromptSubmit'
].sort()
)
expect(Object.keys(hooksConfig.hooks).sort()).toEqual(localManagedCodexEvents())
expect(
isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
).toBe(true)
@@ -133,12 +158,38 @@ describe('CodexHookService', () => {
expect(trustConfig).toContain(':permission_request:0:0')
})
it('drops plugin manager metadata from runtime hooks.json during install', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
join(managedCodexHome, 'hooks.json'),
`${JSON.stringify({
hooks: {},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
})}\n`,
'utf-8'
)
expect(new CodexHookService().install().state).toBe('installed')
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
})
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
// be written verbatim as the hook command, so Codex split it at the space and
// the hook exited with code 1. The managed command uses an encoded launcher
// so the path never appears raw on the cmd.exe command line.
// the hook exited with code 1. Keep spaced paths on the encoded launcher so
// `cmd.exe /C` never sees the raw script path.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command in an encoded launcher when the profile path contains a space (#6078)',
'wraps the managed hook command when the profile path contains a space (#6078)',
() => {
const spaceHome = join(tmpdir(), 'orca home with spaces')
mkdirSync(spaceHome, { recursive: true })
@@ -155,11 +206,9 @@ describe('CodexHookService', () => {
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
for (const eventName of localManagedCodexEvents()) {
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
@@ -167,6 +216,132 @@ describe('CodexHookService', () => {
}
)
// Why: cmd.exe expands `%` and treats `^` as an escape even inside otherwise
// plausible paths. Keep those rare cases on the encoded launcher from #6078.
it.skipIf(process.platform !== 'win32')(
'keeps the encoded launcher when the profile path contains cmd metacharacters',
() => {
const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home')
mkdirSync(metacharHome, { recursive: true })
homedirMock.mockReturnValue(metacharHome)
try {
const systemCodexHome = join(metacharHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const hooksConfig = JSON.parse(
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
for (const eventName of localManagedCodexEvents()) {
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(metacharHome, { recursive: true, force: true })
}
}
)
// Why: the common case — a profile path with no spaces or cmd metacharacters
// — must launch the .cmd directly with no PowerShell, restoring the pre-#6078
// speed that Codex 0.140's synchronous "Running <event> hook" rows expose.
it.skipIf(process.platform !== 'win32')(
'launches the managed .cmd directly when the profile path is cmd-safe',
() => {
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const hooksConfig = JSON.parse(
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
// Why: the temp home is normally cmd-safe; guard so a runner whose tmpdir
// holds an exotic character still asserts the correct (fallback) branch.
const command = hooksConfig.hooks.Stop?.[0]?.hooks?.[0]?.command ?? ''
const cmdSafe = /^[A-Za-z0-9_.:\\~-]+$/.test(join(tmpHome, '.orca', 'agent-hooks'))
if (cmdSafe) {
expect(command).not.toMatch(/powershell/i)
expect(command).toMatch(/\\agent-hooks\\codex-hook\.cmd$/)
} else {
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
}
)
// Why: end-to-end proof the curl-based managed script posts the hook to the
// local listener with UTF-8 (CJK) payloads and a worktreeId containing spaces
// and a `&` — the cases the replaced PowerShell post and form quoting handled.
it.skipIf(process.platform !== 'win32')(
'posts hook payloads via the curl-based managed script preserving UTF-8 and spaced metadata',
async () => {
new CodexHookService().install()
const scriptPath = join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
expect(existsSync(scriptPath)).toBe(true)
// Why: resolve when the listener has fully read the hook POST. spawnSync
// would block the event loop and starve this handler, so the child is
// spawned asynchronously while the server drains the request concurrently.
let resolveReceived: (value: { headers: Record<string, unknown>; body: string }) => void
const receivedPromise = new Promise<{ headers: Record<string, unknown>; body: string }>(
(resolve) => {
resolveReceived = resolve
}
)
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
res.end('ok')
resolveReceived({ headers: req.headers, body: Buffer.concat(chunks).toString('utf-8') })
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = (server.address() as AddressInfo).port
try {
const payload = JSON.stringify({ prompt: '你好世界', hook_event_name: 'UserPromptSubmit' })
// Why: this suite may run inside an Orca-launched terminal whose env
// already carries ORCA_AGENT_HOOK_ENDPOINT/PORT/TOKEN. The managed
// script sources that endpoint file, so leave it out or the hook posts
// to the live Orca instead of this test's listener.
const cleanEnv = { ...process.env }
for (const key of Object.keys(cleanEnv)) {
if (key.startsWith('ORCA_')) {
delete cleanEnv[key]
}
}
const child = spawn('cmd.exe', ['/d', '/c', scriptPath], {
env: {
...cleanEnv,
ORCA_AGENT_HOOK_PORT: String(port),
ORCA_AGENT_HOOK_TOKEN: 'tok123',
ORCA_PANE_KEY: '42:leaf-abc',
ORCA_TAB_ID: '42',
ORCA_WORKTREE_ID: 'C:\\work trees\\my repo & co',
ORCA_AGENT_HOOK_VERSION: '1'
}
})
child.stdin.end(payload)
const exitCode = await new Promise<number>((resolve) => child.on('close', resolve))
expect(exitCode).toBe(0)
const received = await receivedPromise
const params = new URLSearchParams(received.body)
expect(received.headers['x-orca-agent-hook-token']).toBe('tok123')
expect(params.get('paneKey')).toBe('42:leaf-abc')
expect(params.get('worktreeId')).toBe('C:\\work trees\\my repo & co')
expect(JSON.parse(params.get('payload') ?? '{}').prompt).toBe('你好世界')
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
)
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
@@ -216,12 +391,12 @@ describe('CodexHookService', () => {
)
).toBe(true)
expect(
devHooks.hooks.Stop?.some((definition) =>
devHooks.hooks.PreToolUse?.some((definition) =>
isCodexManagedCommand(definition.hooks?.[0]?.command)
)
).toBe(true)
expect(
prodHooks.hooks.Stop?.some((definition) =>
prodHooks.hooks.PreToolUse?.some((definition) =>
isCodexManagedCommand(definition.hooks?.[0]?.command)
)
).toBe(true)
@@ -296,8 +471,8 @@ describe('CodexHookService', () => {
expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.statusMessage).toBe('Running user hook')
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`))
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`))
})
@@ -505,27 +680,25 @@ describe('CodexHookService', () => {
'utf-8'
)
const disabledPostCompactHeader = hookTrustHeader(`${systemHooksPath}:post_compact:0:0`)
const systemToml = upsertHookTrustEntriesInContent('model = "system-model"\n', [
{
sourcePath: systemHooksPath,
eventLabel: 'pre_compact',
groupIndex: 0,
handlerIndex: 0,
command: 'pre-compact-user'
},
{
sourcePath: systemHooksPath,
eventLabel: 'post_compact',
groupIndex: 0,
handlerIndex: 0,
command: 'post-compact-disabled'
}
])
writeFileSync(
join(systemCodexHome, 'config.toml'),
upsertHookTrustEntriesInContent('model = "system-model"\n', [
{
sourcePath: systemHooksPath,
eventLabel: 'pre_compact',
groupIndex: 0,
handlerIndex: 0,
command: 'pre-compact-user'
},
{
sourcePath: systemHooksPath,
eventLabel: 'post_compact',
groupIndex: 0,
handlerIndex: 0,
command: 'post-compact-disabled'
}
]).replace(
`${disabledPostCompactHeader}\nenabled = true`,
`${disabledPostCompactHeader}\nenabled = false`
),
markHookTrustDisabled(systemToml, disabledPostCompactHeader),
'utf-8'
)
@@ -641,17 +814,18 @@ describe('CodexHookService', () => {
'utf-8'
)
const disabledStopHeader = hookTrustHeader(`${systemHooksPath}:stop:0:0`)
const systemToml = upsertHookTrustEntriesInContent('model = "system-model"\n', [
{
sourcePath: systemHooksPath,
eventLabel: 'stop',
groupIndex: 0,
handlerIndex: 0,
command: 'user-stop-hook'
}
])
writeFileSync(
join(systemCodexHome, 'config.toml'),
upsertHookTrustEntriesInContent('model = "system-model"\n', [
{
sourcePath: systemHooksPath,
eventLabel: 'stop',
groupIndex: 0,
handlerIndex: 0,
command: 'user-stop-hook'
}
]).replace(`${disabledStopHeader}\nenabled = true`, `${disabledStopHeader}\nenabled = false`),
markHookTrustDisabled(systemToml, disabledStopHeader),
'utf-8'
)
@@ -720,6 +894,11 @@ describe('CodexHookService', () => {
{ hooks: [{ type: 'command', command: legacyCommand }] }
],
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
},
_managed: {
'external-manager': {
Stop: [0]
}
}
},
null,
@@ -752,9 +931,11 @@ describe('CodexHookService', () => {
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
_managed?: unknown
}
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
expect(systemHooks.hooks.SessionStart).toBeUndefined()
expect(systemHooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')
expect(systemToml).toContain('model = "system-model"')
expect(systemToml).not.toContain(':stop:1:0')
@@ -913,6 +1094,41 @@ describe('CodexHookService', () => {
expect(existsSync(profilePath)).toBe(false)
})
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
managedHooksPath,
`${JSON.stringify(
{
hooks: {
Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }]
},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
},
null,
2
)}\n`,
'utf-8'
)
const status = new CodexHookService().remove()
expect(status.state).toBe('not_installed')
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
})
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
@@ -1107,7 +1323,44 @@ describe('CodexHookService', () => {
expect(trustConfig).not.toContain('model = "runtime-model"')
})
it('repairs duplicate managed SessionStart trust tables on restart install', () => {
it.skipIf(process.platform !== 'win32')(
'treats legacy forward-slash runtime trust keys as installed before canonicalizing on reinstall',
() => {
const service = new CodexHookService()
expect(service.install().state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
const runtimeTomlPath = join(managedCodexHome, 'config.toml')
const canonicalPermissionHeader = hookTrustHeader(
`${managedHooksPath}:permission_request:0:0`
)
const legacyPermissionHeader = `[hooks.state."${escapeTomlBasicString(
`${realpathSync.native(managedHooksPath).replace(/\\/g, '/')}:permission_request:0:0`
)}"]`
const installedToml = readFileSync(runtimeTomlPath, 'utf-8')
expect(installedToml).toContain(canonicalPermissionHeader)
writeFileSync(
runtimeTomlPath,
installedToml.replace(canonicalPermissionHeader, legacyPermissionHeader),
'utf-8'
)
const legacyToml = readFileSync(runtimeTomlPath, 'utf-8')
expect(legacyToml).toContain(legacyPermissionHeader)
expect(service.getStatus().state).toBe('installed')
expect(service.install().state).toBe('installed')
const repairedToml = readFileSync(runtimeTomlPath, 'utf-8')
expect(repairedToml).not.toContain(legacyPermissionHeader)
expect(repairedToml).toContain(canonicalPermissionHeader)
expect(service.getStatus().state).toBe('installed')
}
)
it('repairs duplicate managed PermissionRequest trust tables on restart install', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
@@ -1118,21 +1371,24 @@ describe('CodexHookService', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
const runtimeTomlPath = join(managedCodexHome, 'config.toml')
const sessionStartHeader = hookTrustHeader(`${managedHooksPath}:session_start:0:0`)
const permissionRequestHeader = hookTrustHeader(`${managedHooksPath}:permission_request:0:0`)
const installedToml = readFileSync(runtimeTomlPath, 'utf-8')
const sessionStartIndex = installedToml.indexOf(sessionStartHeader)
expect(sessionStartIndex).not.toBe(-1)
const permissionRequestIndex = installedToml.indexOf(permissionRequestHeader)
expect(permissionRequestIndex).not.toBe(-1)
const nextHeaderIndex = installedToml.indexOf(
'\n[',
sessionStartIndex + sessionStartHeader.length
permissionRequestIndex + permissionRequestHeader.length
)
const sessionStartBlock = installedToml
.slice(sessionStartIndex, nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex)
const permissionRequestBlock = installedToml
.slice(
permissionRequestIndex,
nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex
)
.trimEnd()
const staleDisabledBlock = sessionStartBlock
const staleDisabledBlock = permissionRequestBlock
.replace('enabled = true', 'enabled = false')
.replace(/trusted_hash = "[^"]+"/, 'trusted_hash = "sha256:STALE_DISABLED"')
const staleEnabledBlock = sessionStartBlock.replace(
const staleEnabledBlock = permissionRequestBlock.replace(
/trusted_hash = "[^"]+"/,
'trusted_hash = "sha256:STALE_ENABLED"'
)
@@ -1140,20 +1396,20 @@ describe('CodexHookService', () => {
runtimeTomlPath,
`${installedToml.slice(
0,
sessionStartIndex
permissionRequestIndex
)}${staleDisabledBlock}\n\n${staleEnabledBlock}${installedToml.slice(
nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex
)}`,
'utf-8'
)
expect(readFileSync(runtimeTomlPath, 'utf-8').split(sessionStartHeader)).toHaveLength(3)
expect(readFileSync(runtimeTomlPath, 'utf-8').split(permissionRequestHeader)).toHaveLength(3)
// Why: preserving `enabled = false` is the repair contract; status can be
// partial because the user-disabled managed hook remains disabled.
expect(['installed', 'partial']).toContain(service.install().state)
const repairedToml = readFileSync(runtimeTomlPath, 'utf-8')
expect(repairedToml.split(sessionStartHeader)).toHaveLength(2)
expect(repairedToml.split(permissionRequestHeader)).toHaveLength(2)
expect(repairedToml).toContain('enabled = false')
expect(repairedToml).not.toContain('STALE_DISABLED')
expect(repairedToml).not.toContain('STALE_ENABLED')
+65 -22
View File
@@ -6,7 +6,7 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
buildWindowsAgentHookCurlPostCommand,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
MANAGED_HOOK_TIMEOUT_SECONDS,
@@ -31,6 +31,7 @@ import {
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -62,6 +63,12 @@ function getConfigPath(): string {
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
}
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
// Why: Codex rejects unknown top-level hooks.json fields, so plugin manager
// bookkeeping such as `_managed` must not survive Orca's rewrite.
writeHooksJson(configPath, { hooks })
}
function getCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
@@ -113,10 +120,17 @@ function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
// Why: a Windows script path is cmd-safe when it holds only characters cmd.exe
// passes through untouched (drive letter, backslash, dot, dash, underscore).
// Spaces or cmd metacharacters force the encoded launcher; `cmd.exe /C` splits
// bare `.cmd` paths at spaces before the script can run.
const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
if (process.platform !== 'win32') {
return wrapPosixHookCommand(scriptPath)
}
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
function getSystemConfigPath(): string {
@@ -209,7 +223,10 @@ function removeStaleRuntimeHookTrustEntries(
expectedEntries: readonly CodexTrustEntry[]
): void {
const expectedHashes = new Map(
expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)])
expectedEntries.map((entry) => [
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
computeTrustedHash(entry)
])
)
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
const staleKeys: string[] = []
@@ -218,7 +235,7 @@ function removeStaleRuntimeHookTrustEntries(
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
continue
}
if (expectedHashes.get(key) === state.trustedHash) {
if (expectedHashes.get(normalizeHookTrustKeyForLookup(key)) === state.trustedHash) {
continue
}
staleKeys.push(key)
@@ -454,6 +471,28 @@ function escapeRegex(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildHookTrustHeaderKeyPattern(key: string): string {
const keyVariants = [key]
const parsed = parseTrustKey(key)
if (parsed && /^[A-Za-z]:[\\/]|^\\\\/.test(parsed.sourcePath)) {
const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
keyVariants.push(
`${parsed.sourcePath.replace(/\\/g, '/')}${suffix}`,
`${parsed.sourcePath.replace(/\//g, '\\')}${suffix}`
)
}
const alternatives = [...new Set(keyVariants)].flatMap((variant) => {
const quoted = [`"${escapeRegex(escapeTomlString(variant))}"`]
if (!variant.includes("'")) {
// Why: tolerate raw-backslash literal keys left by Codex/manual approval
// while Orca repairs mirrored runtime trust across both Windows variants.
quoted.push(`'${escapeRegex(variant)}'`)
}
return quoted
})
return `(?:${alternatives.join('|')})`
}
function applyMirroredRuntimeUserHookTrustStates(
tomlPath: string,
entries: readonly MirroredRuntimeUserHookTrustEntry[]
@@ -465,9 +504,10 @@ function applyMirroredRuntimeUserHookTrustStates(
const existing = readFileSync(tomlPath, 'utf-8')
let updated = existing
for (const { entry, enabled } of entries) {
const escapedKey = escapeRegex(escapeTomlString(computeTrustKey(entry)))
const headerKeyPattern = buildHookTrustHeaderKeyPattern(computeTrustKey(entry))
const pattern = new RegExp(
`(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)`
`(\\[hooks\\.state\\.${headerKeyPattern}\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)`,
'g'
)
updated = updated.replace(pattern, `$1${enabled}`)
}
@@ -532,6 +572,8 @@ function cleanupLegacySystemManagedHooks(): void {
// Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in
// ~/.codex would keep external Codex sessions reporting into Orca.
if (removedManagedHook) {
// Why: this is the user's system hooks file, not Orca's runtime copy.
// Remove only stale Orca hook entries and preserve other managers' metadata.
writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks })
}
removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries)
@@ -620,9 +662,11 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void {
// recognize (and clean up) its own managed trust entries.
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const expectedHash = computeTrustedHash(expectedEntry)
const legacyHash = computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
if (state.trustedHash !== expectedHash && state.trustedHash !== legacyHash) {
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) {
continue
}
ourKeys.push(key)
@@ -650,7 +694,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
buildWindowsAgentHookPostCommand('codex'),
buildWindowsAgentHookCurlPostCommand('codex'),
'exit /b 0',
''
].join('\r\n')
@@ -889,7 +933,7 @@ export class CodexHookService {
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, nextHooks)
// Why: trust entries write last so a half-write can't leave a hash
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
@@ -979,7 +1023,9 @@ export class CodexHookService {
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
// Why: SSH installs edit the user's remote ~/.codex/hooks.json directly.
// Preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
@@ -1035,7 +1081,7 @@ export class CodexHookService {
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
config.hooks = hookPlan.hooks
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, hookPlan.hooks)
try {
const tomlPath = getCodexConfigTomlPath()
@@ -1080,7 +1126,6 @@ export class CodexHookService {
}
const nextHooks = { ...config.hooks }
let removedManagedHooks = false
// Why: same broad matcher as install(), so remove() also cleans up stale
// entries from older builds even if the current scriptPath has moved.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
@@ -1092,18 +1137,16 @@ export class CodexHookService {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) {
removedManagedHooks = true
}
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
if (configExists && removedManagedHooks) {
config.hooks = nextHooks
writeHooksJson(configPath, config)
if (configExists) {
// Why: remove() can be the only repair path for a parseable runtime file
// whose top-level plugin metadata makes Codex reject hooks.json.
writeCodexHooksJson(configPath, nextHooks)
}
// Why: also drop our trust entries so config.toml doesn't accumulate dead
+5 -6
View File
@@ -20,6 +20,9 @@ vi.mock('os', async () => {
import { CommandCodeHookService } from './hook-service'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
describe('CommandCodeHookService', () => {
let homeDir: string
@@ -49,9 +52,7 @@ describe('CommandCodeHookService', () => {
expect(config.hooks.PostToolUse[0].matcher).toBe('.*')
expect(config.hooks.Stop[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /command-code-hook/
process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /command-code-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
@@ -79,9 +80,7 @@ describe('CommandCodeHookService', () => {
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
const command = config.hooks.PreToolUse[0].hooks[0].command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
@@ -201,6 +201,16 @@ describe('shouldRecordProcessGoneCrash', () => {
expectedTeardown: 'none'
})
).toBe(false)
expect(
shouldRecordProcessGoneCrash({
source: 'child',
processType: 'Utility',
serviceName: 'video_capture.mojom.VideoCaptureService',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'none'
})
).toBe(false)
})
it('still records unknown child process crashes', () => {
@@ -236,6 +246,16 @@ describe('shouldRecordProcessGoneCrash', () => {
expectedTeardown: 'none'
})
).toBe(true)
expect(
shouldRecordProcessGoneCrash({
source: 'child',
processType: 'Utility',
serviceName: 'video_capture.mojom.VideoCaptureService',
reason: 'launch-failed',
exitCode: -1,
expectedTeardown: 'none'
})
).toBe(true)
})
it('still records renderer kills from the recent Linux crash-report cluster', () => {
@@ -274,6 +294,36 @@ describe('shouldRecordProcessGoneCrash', () => {
).toBe(true)
})
it('records Windows renderer killed exit 1 outside expected lifecycle teardown only', () => {
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'none'
})
).toBe(true)
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'renderer-reload'
})
).toBe(false)
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'app-shutdown'
})
).toBe(false)
})
it('records non-SIGTERM child-process killed events during renderer-only reloads', () => {
expect(
shouldRecordProcessGoneCrash({
@@ -5,7 +5,10 @@ const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004])
const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu'])
const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'audio.mojom.AudioService',
'network.mojom.NetworkService'
'network.mojom.NetworkService',
// Why: Windows media/screen capture can churn this Chromium utility without
// taking down Orca; prompting users for those child exits is noise.
'video_capture.mojom.VideoCaptureService'
])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure', 'launch-failed'])
@@ -4,7 +4,7 @@ import { getProcessGoneDedupeKey, ProcessGoneDedupe } from './process-gone-dedup
describe('ProcessGoneDedupe', () => {
it('suppresses duplicate keys inside the dedupe window', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
const key = getProcessGoneDedupeKey('GPU', 'crashed', 5)
const key = getProcessGoneDedupeKey('child', 'GPU', 'crashed', 5)
expect(dedupe.shouldRecord(key, 1_000)).toBe(true)
expect(dedupe.shouldRecord(key, 2_999)).toBe(false)
@@ -32,4 +32,38 @@ describe('ProcessGoneDedupe', () => {
expect(dedupe.size).toBe(3)
expect(dedupe.shouldRecord('a', 1_004)).toBe(true)
})
it('coalesces renderer crash bursts across crash reasons and exit codes', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', -36861), 1_000)
).toBe(true)
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904), 1_284)
).toBe(false)
expect(
dedupe.shouldRecord(
getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
1_898
)
).toBe(false)
expect(
dedupe.shouldRecord(
getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
3_000
)
).toBe(true)
})
it('keeps child process crash tuples distinct inside renderer burst windows', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'crashed', 1), 1_000)
).toBe(true)
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'killed', 1), 1_100)
).toBe(true)
})
})
@@ -54,11 +54,17 @@ export class ProcessGoneDedupe {
}
export function getProcessGoneDedupeKey(
source: 'renderer' | 'child',
processType: string,
reason: string,
exitCode: number | null
): string {
return `${processType}:${reason}:${exitCode ?? 'null'}`
// Why: one renderer death can surface as crashed/oom/launch-failed in a
// burst. Coalesce that prompt noise while keeping child identities precise.
if (source === 'renderer') {
return `${source}:${processType}`
}
return `${source}:${processType}:${reason}:${exitCode ?? 'null'}`
}
export const processGoneDedupe = new ProcessGoneDedupe()
+6 -8
View File
@@ -29,6 +29,8 @@ const CURSOR_EVENTS = [
]
const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
describe('CursorHookService', () => {
let homeDir: string
@@ -59,9 +61,7 @@ describe('CursorHookService', () => {
for (const eventName of CURSOR_EVENTS) {
const definition = config.hooks[eventName]?.[0]
expect(definition?.command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /cursor-hook/
process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /cursor-hook/
)
if (process.platform !== 'win32') {
expect(definition?.command).toContain(join(homeDir, '.orca'))
@@ -75,7 +75,7 @@ describe('CursorHookService', () => {
)
expect(script).toContain('/hook/cursor')
if (process.platform === 'win32') {
expect(script).toContain('powershell -NoProfile')
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
expect(script).toContain('payload=$(cat)')
}
@@ -100,9 +100,7 @@ describe('CursorHookService', () => {
for (const eventName of ['beforeSubmitPrompt', 'stop']) {
const command = config.hooks[eventName]?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
@@ -144,7 +142,7 @@ describe('CursorHookService', () => {
expect(
promptCommands.filter((command) =>
process.platform === 'win32'
? command?.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
? command !== undefined && WINDOWS_POWERSHELL_LAUNCHER.test(command)
: command?.includes(CURSOR_SCRIPT_FILE_NAME)
)
).toHaveLength(1)
+2
View File
@@ -8,6 +8,7 @@
*/
import { startDaemon, type DaemonHandle } from './daemon-main'
import { createPtySubprocess } from './pty-subprocess'
import { warmPwshAvailabilityCache } from '../pwsh'
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
let socketPath = ''
@@ -32,6 +33,7 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri
async function main(): Promise<void> {
const { socketPath, tokenPath } = parseArgs(process.argv.slice(2))
void warmPwshAvailabilityCache()
// Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch
// blocks (e.g. writing to a PTY whose fd was closed between the native
+126 -1
View File
@@ -48,12 +48,14 @@ import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_OPENCODE_CONFIG_DIR',
'ORCA_MIMOCODE_HOME',
'ORCA_PI_CODING_AGENT_DIR',
'ORCA_OMP_CODING_AGENT_DIR',
'ORCA_CODEX_HOME'
] as const
const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]
const ZSH_SHELL_READY_DIR = /shell-ready[\\/]zsh/
const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
const itOnMacHost = process.platform === 'darwin' ? it : it.skip
function mockPtyProcess(pid = 12345) {
@@ -81,6 +83,7 @@ function mockPtyProcess(pid = 12345) {
describe('createPtySubprocess', () => {
const savedWrapperEnv: Partial<Record<(typeof ORCA_SHELL_WRAPPER_ENV)[number], string>> = {}
let previousUserDataPath: string | undefined
let previousPowerlevelWizardDisable: string | undefined
let userDataPath: string
beforeEach(() => {
@@ -93,8 +96,10 @@ describe('createPtySubprocess', () => {
validateWorkingDirectoryMock.mockClear()
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV]
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
delete process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV]
for (const key of ORCA_SHELL_WRAPPER_ENV) {
savedWrapperEnv[key] = process.env[key]
delete process.env[key]
@@ -107,6 +112,11 @@ describe('createPtySubprocess', () => {
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
if (previousPowerlevelWizardDisable === undefined) {
delete process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV]
} else {
process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] = previousPowerlevelWizardDisable
}
rmSync(userDataPath, { recursive: true, force: true })
for (const key of ORCA_SHELL_WRAPPER_ENV) {
if (savedWrapperEnv[key] === undefined) {
@@ -149,6 +159,29 @@ describe('createPtySubprocess', () => {
)
})
it('suppresses the first-run Powerlevel10k wizard for daemon terminals', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: { SHELL: '/bin/bash' }
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBe('true')
})
itOnMacHost('repairs a deleted macOS daemon cwd before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -319,6 +352,42 @@ describe('createPtySubprocess', () => {
}
})
it('serves Unix agent foreground when node-pty reports an agent child process', async () => {
const proc = mockPtyProcess()
proc.process = 'uv'
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('uv')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'uv',
expect.any(Object)
)
resolveForeground('claude')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('claude'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not let stale shell enrichment clear a newer direct agent foreground cache', async () => {
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
@@ -906,6 +975,35 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when MiMo home must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/zsh',
MIMOCODE_HOME: '/tmp/orca-mimocode-overlay',
ORCA_MIMOCODE_HOME: '/tmp/orca-mimocode-overlay'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR)
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when typed OMP commands need the status extension', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -1798,12 +1896,39 @@ describe('createPtySubprocess', () => {
expect.objectContaining({
env: expect.objectContaining({
ORCA_TERMINAL_HANDLE: 'term_wsl',
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u'
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
})
})
)
})
it('does not mark deleted Powerlevel10k wizard env for daemon WSL import', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBeUndefined()
expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV)
})
it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+19 -2
View File
@@ -25,6 +25,10 @@ import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
POWERLEVEL10K_WIZARD_DISABLE_ENV,
seedPowerlevel10kWizardEnv
} from '../pty/powerlevel10k-wizard-env'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
@@ -593,6 +597,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
shellLaunch =
env.ORCA_ATTRIBUTION_SHIM_DIR ||
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_MIMOCODE_HOME ||
env.ORCA_OMP_STATUS_EXTENSION ||
env.ORCA_CODEX_HOME ||
env.ORCA_AGENT_TEAMS_SHIM_DIR
@@ -604,6 +609,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
shellArgs = shellLaunch?.args ?? ['-l']
}
seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete })
if (
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
process.platform === 'win32' &&
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
) {
addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
promoteAgentTeamsShimPath(env, opts.env?.PATH)
// Why: asar packaging can strip the +x bit from node-pty's spawn-helper
@@ -719,7 +732,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess))
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
// Why: agent-spawned helper processes can become the PTY foreground
// child; the Unix process tree can still identify the parent agent.
process.platform !== 'win32')
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || !proc.pid) {
return
@@ -743,7 +760,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon foreground reads are sync and run on the IPC hot path.
// Refresh shell/wrapper-derived identities (powershell/node -> codex/etc.)
// Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.)
// in the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess, {
contextPaths: agentForegroundContextPaths
+5
View File
@@ -472,6 +472,8 @@ describePosix('daemon shell-ready launch config', () => {
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const mimoRestoreLine =
'[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
@@ -479,6 +481,9 @@ describePosix('daemon shell-ready launch config', () => {
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(mimoRestoreLine)
expect(zlogin).toContain(mimoRestoreLine)
expect(bashRc).toContain(mimoRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
+3
View File
@@ -114,6 +114,7 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@@ -226,6 +227,7 @@ __orca_restore_agent_teams_path() {
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
fi
@@ -288,6 +290,7 @@ __orca_restore_agent_teams_path() {
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
+5 -6
View File
@@ -24,6 +24,9 @@ vi.mock('os', async () => {
import { DroidHookService } from './hook-service'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
describe('DroidHookService', () => {
let homeDir: string
let userDataDir: string
@@ -71,9 +74,7 @@ describe('DroidHookService', () => {
expect(config.hooks.PermissionRequest[0].matcher).toBe('*')
expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /droid-hook/
process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /droid-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
@@ -100,9 +101,7 @@ describe('DroidHookService', () => {
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
+6 -5
View File
@@ -25,6 +25,9 @@ vi.mock('os', async (importOriginal) => {
import { GeminiHookService } from './hook-service'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
describe('GeminiHookService', () => {
let homeDir: string
let userDataDir: string
@@ -102,7 +105,7 @@ describe('GeminiHookService', () => {
expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent')
const managedCommandPattern =
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
? WINDOWS_POWERSHELL_LAUNCHER
: new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern)
expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern)
@@ -129,9 +132,7 @@ describe('GeminiHookService', () => {
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
@@ -183,7 +184,7 @@ describe('GeminiHookService', () => {
expect(preToolCommands).toEqual(['echo user-authored'])
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
? WINDOWS_POWERSHELL_LAUNCHER
: new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
)
})
@@ -92,8 +92,10 @@ describe('local upstream negative cache', () => {
it('keeps an older automatic negative probe from overwriting a strict positive result', async () => {
let originBranchExists = false
let deferredOriginReject: ((error: Error) => void) | null = null
let statusCommandCalls = 0
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('status')) {
statusCommandCalls += 1
return {
stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n'
}
@@ -123,6 +125,7 @@ describe('local upstream negative cache', () => {
originBranchExists = true
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
expect(statusCommandCalls).toBe(2)
if (!deferredOriginReject) {
throw new Error('expected deferred origin reject')
}
+325 -1
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'fs'
import path from 'path'
import {
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
@@ -69,15 +70,41 @@ import {
bulkUnstageFiles,
clearEffectiveUpstreamStatusCacheForTests,
detectConflictOperation,
getBranchDiff,
discardChanges,
getCommitDiff,
getBranchCompare,
getCommitCompare,
getDiff,
getStagedCommitContext,
getStatus,
isWithinWorktree
isWithinWorktree,
stageFile
} from './status'
function deferredBuffer(content: string): {
promise: Promise<{ stdout: Buffer }>
resolve: () => void
} {
let resolve!: (value: { stdout: Buffer }) => void
const promise = new Promise<{ stdout: Buffer }>((innerResolve) => {
resolve = innerResolve
})
return {
promise,
resolve: () => resolve({ stdout: Buffer.from(content) })
}
}
async function waitForMockCalls(mock: ReturnType<typeof vi.fn>, calls: number): Promise<void> {
for (let i = 0; i < 20; i++) {
if (mock.mock.calls.length >= calls) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
describe('discardChanges', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
@@ -444,6 +471,235 @@ describe('getDiff', () => {
mimeType: 'application/pdf'
})
})
it('coalesces concurrent identical staged diff reads while in flight', async () => {
const leftBlob = deferredBuffer('head-content\n')
const rightBlob = deferredBuffer('index-content\n')
const pendingBuffers = [leftBlob, rightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise)
const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true))
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1)
leftBlob.resolve()
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
rightBlob.resolve()
const results = await Promise.all(reads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
expect(results.every((result) => result.kind === 'text')).toBe(true)
gitExecFileAsyncBufferMock
.mockResolvedValueOnce({ stdout: Buffer.from('fresh-head\n') })
.mockResolvedValueOnce({ stdout: Buffer.from('fresh-index\n') })
await getDiff('/repo', 'src/file.ts', true)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(4)
})
it('clears pending diff reads when a mutation runs', async () => {
const firstBlob = deferredBuffer('head-content\n')
const secondBlob = deferredBuffer('fresh-head-content\n')
const pendingBuffers = [firstBlob, secondBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBuffers.shift()!.promise)
readFileMock.mockResolvedValue(Buffer.from('working-tree\n'))
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
const first = getDiff('/repo', 'src/file.ts', false)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
await stageFile('/repo', 'src/file.ts')
const second = getDiff('/repo', 'src/file.ts', false)
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
firstBlob.resolve()
secondBlob.resolve()
await Promise.all([first, second])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['add', '--', ':(literal)src/file.ts'],
expect.objectContaining({ cwd: '/repo' })
)
})
it('coalesces concurrent identical branch and commit diff reads while in flight', async () => {
const branchLeftBlob = deferredBuffer('branch-left\n')
const branchRightBlob = deferredBuffer('branch-right\n')
const pendingBranchBuffers = [branchLeftBlob, branchRightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingBranchBuffers.shift()!.promise)
const branchReads = Array.from({ length: 8 }, () =>
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
branchLeftBlob.resolve()
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
branchRightBlob.resolve()
await Promise.all(branchReads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
gitExecFileAsyncBufferMock.mockReset()
const commitLeftBlob = deferredBuffer('commit-left\n')
const commitRightBlob = deferredBuffer('commit-right\n')
const pendingCommitBuffers = [commitLeftBlob, commitRightBlob]
gitExecFileAsyncBufferMock.mockImplementation(async () => pendingCommitBuffers.shift()!.promise)
const commitReads = Array.from({ length: 8 }, () =>
getCommitDiff('/repo', {
parentOid: 'd'.repeat(40),
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
commitLeftBlob.resolve()
await waitForMockCalls(gitExecFileAsyncBufferMock, 2)
commitRightBlob.resolve()
await Promise.all(commitReads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
})
it('coalesces logically identical branch and commit diff args regardless of property order', async () => {
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
await Promise.all([
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
getBranchDiff('/repo', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
headOid: 'c'.repeat(40),
mergeBase: 'b'.repeat(40)
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
gitExecFileAsyncBufferMock.mockClear()
await Promise.all([
getCommitDiff('/repo', {
parentOid: 'd'.repeat(40),
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
getCommitDiff('/repo', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
commitOid: 'e'.repeat(40),
parentOid: 'd'.repeat(40)
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2)
})
it('keeps distinct diff inputs on separate in-flight reads', async () => {
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree\n'))
await Promise.all([
getDiff('/repo', 'src/file.ts', false, false),
getDiff('/repo', 'src/file.ts', false, true),
getDiff('/repo', 'src/file.ts', true, false),
getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'ubuntu' }),
getDiff('/repo', 'src/file.ts', true, false, { wslDistro: 'debian' })
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(8)
gitExecFileAsyncBufferMock.mockReset()
gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('blob\n') })
await Promise.all([
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'd'.repeat(40),
filePath: 'src/file.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-a.ts'
}),
getBranchDiff('/repo', {
mergeBase: 'b'.repeat(40),
headOid: 'c'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-b.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts'
}),
getCommitDiff('/repo', {
parentOid: 'a'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-a.ts'
}),
getCommitDiff('/repo', {
parentOid: 'e'.repeat(40),
commitOid: 'f'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-b.ts'
})
])
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(16)
})
it('coalesces parentless root commit diff reads without reading a left-side blob', async () => {
const rightBlob = deferredBuffer('root-content\n')
gitExecFileAsyncBufferMock.mockImplementation(async () => rightBlob.promise)
const reads = Array.from({ length: 8 }, () =>
getCommitDiff('/repo', {
parentOid: null,
commitOid: 'e'.repeat(40),
filePath: 'src/file.ts'
})
)
await waitForMockCalls(gitExecFileAsyncBufferMock, 1)
rightBlob.resolve()
await Promise.all(reads)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1)
})
})
describe('getStatus', () => {
@@ -461,6 +717,74 @@ describe('getStatus', () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
it('benchmarks concurrent status burst subprocess pressure', async () => {
const benchPath = process.env.ORCA_GIT_STATUS_COALESCING_BENCH_JSON
if (!benchPath) {
return
}
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({ stdout: '' })
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '' })
}
return Promise.resolve({ stdout: '' })
})
const startedAt = performance.now()
await Promise.all(Array.from({ length: 10 }, () => getStatus('/repo')))
const durationMs = performance.now() - startedAt
const statusCommandCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('status')
).length
const { mkdirSync, writeFileSync } = await vi.importActual<typeof NodeFs>('fs')
mkdirSync(path.dirname(benchPath), { recursive: true })
writeFileSync(
benchPath,
JSON.stringify({
scenario: 'git-status-concurrent-burst',
concurrentCalls: 10,
statusCommandCalls,
durationMs
})
)
})
it('coalesces identical in-flight status reads without caching after settle', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
let statusCommandCalls = 0
const releaseStatusReads: (() => void)[] = []
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
statusCommandCalls += 1
return new Promise<{ stdout: string }>((resolve) => {
releaseStatusReads.push(() => resolve({ stdout: '' }))
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '' })
}
return Promise.resolve({ stdout: '' })
})
const sharedRead = Promise.all([getStatus('/repo'), getStatus('/repo'), getStatus('/repo')])
await vi.waitFor(() => expect(statusCommandCalls).toBe(1))
releaseStatusReads.splice(0).forEach((release) => release())
await sharedRead
expect(statusCommandCalls).toBe(1)
const settledRead = getStatus('/repo')
await vi.waitFor(() => expect(statusCommandCalls).toBe(2))
releaseStatusReads.splice(0).forEach((release) => release())
await settledRead
expect(statusCommandCalls).toBe(2)
})
it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD'))
+221 -68
View File
@@ -45,6 +45,7 @@ import {
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
@@ -63,13 +64,23 @@ type EffectiveUpstreamStatusCacheEntry = {
const effectiveUpstreamStatusCache = new Map<string, EffectiveUpstreamStatusCacheEntry>()
const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
const statusReadsInFlight = new Map<string, Promise<GitStatusResult>>()
function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] {
return [options.wslDistro ?? null]
}
// Why: status tests reuse this reset hook, so every cross-call memoization layer
// must reset together even though the historical name mentions upstream only.
export function clearEffectiveUpstreamStatusCacheForTests(): void {
effectiveUpstreamStatusCache.clear()
effectiveUpstreamStatusInFlight.clear()
retiredEffectiveUpstreamStatusInFlight.clear()
gitDiffReadDedupe.clear()
effectiveUpstreamStatusWriteGeneration.clear()
statusReadsInFlight.clear()
}
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
@@ -96,6 +107,45 @@ export type GetStatusOptions = GitRuntimeOptions & {
export async function getStatus(
worktreePath: string,
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
gitDiffReadDedupe.clear()
// Why: dedupe only concurrent identical reads; after settle, callers must
// execute a fresh status read rather than observing a cached result.
const cacheKey = getStatusReadKey(worktreePath, options)
const inFlightStatus = statusReadsInFlight.get(cacheKey)
if (inFlightStatus) {
return inFlightStatus
}
const statusPromise = runGetStatus(worktreePath, options)
statusReadsInFlight.set(cacheKey, statusPromise)
try {
return await statusPromise
} finally {
if (statusReadsInFlight.get(cacheKey) === statusPromise) {
statusReadsInFlight.delete(cacheKey)
}
}
}
function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string {
// Why: each key part can change the output shape or runtime routing.
const limit =
typeof options.limit === 'number' && Number.isInteger(options.limit) && options.limit >= 0
? options.limit
: DEFAULT_GIT_STATUS_LIMIT
return [
worktreePath,
options.wslDistro ?? '',
options.includeIgnored === true,
options.bypassEffectiveUpstreamNegativeCache === true,
limit
].join('\0')
}
async function runGetStatus(
worktreePath: string,
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
let effectiveUpstreamStatus: GitUpstreamStatus | undefined
let statusSucceeded = false
@@ -680,6 +730,26 @@ export async function getDiff(
staged: boolean,
compareAgainstHead = false,
options: GitRuntimeOptions = {}
): Promise<GitDiffResult> {
return gitDiffReadDedupe.run(
stableInFlightKey([
'diff',
worktreePath,
filePath,
staged,
compareAgainstHead,
...gitRuntimeOptionsKey(options)
]),
() => loadDiff(worktreePath, filePath, staged, compareAgainstHead, options)
)
}
async function loadDiff(
worktreePath: string,
filePath: string,
staged: boolean,
compareAgainstHead: boolean,
options: GitRuntimeOptions
): Promise<GitDiffResult> {
let originalContent = ''
let modifiedContent = ''
@@ -810,6 +880,30 @@ export async function getBranchDiff(
oldPath?: string
},
options: GitRuntimeOptions = {}
): Promise<GitDiffResult> {
return gitDiffReadDedupe.run(
stableInFlightKey([
'branchDiff',
worktreePath,
args.headOid,
args.mergeBase,
args.filePath,
args.oldPath ?? null,
...gitRuntimeOptionsKey(options)
]),
() => loadBranchDiff(worktreePath, args, options)
)
}
async function loadBranchDiff(
worktreePath: string,
args: {
headOid: string
mergeBase: string
filePath: string
oldPath?: string
},
options: GitRuntimeOptions
): Promise<GitDiffResult> {
try {
const leftPath = args.oldPath ?? args.filePath
@@ -899,6 +993,30 @@ export async function getCommitDiff(
oldPath?: string
},
options: GitRuntimeOptions = {}
): Promise<GitDiffResult> {
return gitDiffReadDedupe.run(
stableInFlightKey([
'commitDiff',
worktreePath,
args.commitOid,
args.parentOid ?? null,
args.filePath,
args.oldPath ?? null,
...gitRuntimeOptionsKey(options)
]),
() => loadCommitDiff(worktreePath, args, options)
)
}
async function loadCommitDiff(
worktreePath: string,
args: {
commitOid: string
parentOid?: string | null
filePath: string
oldPath?: string
},
options: GitRuntimeOptions
): Promise<GitDiffResult> {
try {
const leftPath = args.oldPath ?? args.filePath
@@ -1274,10 +1392,15 @@ export async function stageFile(
filePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
await gitExecFileAsync(
['add', '--', literalPathspec(filePath)],
gitOptionsForWorktree(worktreePath, options)
)
gitDiffReadDedupe.clear()
try {
await gitExecFileAsync(
['add', '--', literalPathspec(filePath)],
gitOptionsForWorktree(worktreePath, options)
)
} finally {
gitDiffReadDedupe.clear()
}
}
/**
@@ -1288,9 +1411,14 @@ export async function unstageFile(
filePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], {
...gitOptionsForWorktree(worktreePath, options)
})
gitDiffReadDedupe.clear()
try {
await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], {
...gitOptionsForWorktree(worktreePath, options)
})
} finally {
gitDiffReadDedupe.clear()
}
}
export async function getStagedCommitContext(
@@ -1346,6 +1474,7 @@ export async function commitChanges(
message: string,
options: GitRuntimeOptions = {}
): Promise<{ success: boolean; error?: string }> {
gitDiffReadDedupe.clear()
try {
await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options))
return { success: true }
@@ -1367,6 +1496,8 @@ export async function commitChanges(
readStringField('stdout') ??
(error instanceof Error ? error.message : 'Commit failed')
return { success: false, error: errorMessage }
} finally {
gitDiffReadDedupe.clear()
}
}
@@ -1378,35 +1509,40 @@ export async function discardChanges(
filePath: string,
options: GitRuntimeOptions = {}
): Promise<void> {
gitDiffReadDedupe.clear()
const resolvedWorktree = path.resolve(worktreePath)
const resolvedTarget = path.resolve(worktreePath, filePath)
if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
let tracked = false
try {
await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], {
...gitOptionsForWorktree(worktreePath, options)
})
tracked = true
} catch {
// File is not tracked by git
}
if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
if (tracked) {
await gitExecFileAsync(
['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)],
{
let tracked = false
try {
await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], {
...gitOptionsForWorktree(worktreePath, options)
}
)
return
}
})
tracked = true
} catch {
// File is not tracked by git
}
await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) =>
cleanUntrackedPaths(worktreePath, [targetPath], options)
)
if (tracked) {
await gitExecFileAsync(
['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)],
{
...gitOptionsForWorktree(worktreePath, options)
}
)
return
}
await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) =>
cleanUntrackedPaths(worktreePath, [targetPath], options)
)
} finally {
gitDiffReadDedupe.clear()
}
}
function normalizeGitPathForCompare(filePath: string): string {
@@ -1475,39 +1611,46 @@ export async function bulkDiscardChanges(
filePaths: string[],
options: GitRuntimeOptions = {}
): Promise<void> {
gitDiffReadDedupe.clear()
if (filePaths.length === 0) {
return
}
const resolvedWorktree = path.resolve(worktreePath)
for (const filePath of filePaths) {
const resolvedTarget = path.resolve(worktreePath, filePath)
if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
}
const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options)
const trackedPaths = filePaths.filter((filePath) => isTrackedPathSpec(filePath, trackedPathSpecs))
const untrackedPaths = filePaths.filter(
(filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs)
)
await removeSafeUntrackedDiscardTargets(
worktreePath,
untrackedPaths,
(targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options),
async () => {
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(
['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)],
{
...gitOptionsForWorktree(worktreePath, options)
}
)
try {
const resolvedWorktree = path.resolve(worktreePath)
for (const filePath of filePaths) {
const resolvedTarget = path.resolve(worktreePath, filePath)
if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
}
)
const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options)
const trackedPaths = filePaths.filter((filePath) =>
isTrackedPathSpec(filePath, trackedPathSpecs)
)
const untrackedPaths = filePaths.filter(
(filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs)
)
await removeSafeUntrackedDiscardTargets(
worktreePath,
untrackedPaths,
(targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options),
async () => {
for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) {
const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(
['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)],
{
...gitOptionsForWorktree(worktreePath, options)
}
)
}
}
)
} finally {
gitDiffReadDedupe.clear()
}
}
export function isWithinWorktree(
@@ -1532,15 +1675,20 @@ export async function bulkStageFiles(
filePaths: string[],
options: GitRuntimeOptions = {}
): Promise<void> {
gitDiffReadDedupe.clear()
if (filePaths.length === 0) {
return
}
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(
['add', '--', ...chunk.map(literalPathspec)],
gitOptionsForWorktree(worktreePath, options)
)
try {
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(
['add', '--', ...chunk.map(literalPathspec)],
gitOptionsForWorktree(worktreePath, options)
)
}
} finally {
gitDiffReadDedupe.clear()
}
}
@@ -1552,13 +1700,18 @@ export async function bulkUnstageFiles(
filePaths: string[],
options: GitRuntimeOptions = {}
): Promise<void> {
gitDiffReadDedupe.clear()
if (filePaths.length === 0) {
return
}
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], {
...gitOptionsForWorktree(worktreePath, options)
})
try {
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], {
...gitOptionsForWorktree(worktreePath, options)
})
}
} finally {
gitDiffReadDedupe.clear()
}
}
+44
View File
@@ -21,6 +21,7 @@ vi.mock('./runner', () => ({
import {
addSparseWorktree,
addWorktree,
listWorktreeGraph,
moveWorktree,
parseWorktreeList,
removeWorktree
@@ -235,6 +236,49 @@ bare
})
})
describe('listWorktreeGraph', () => {
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
})
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isMainWorktree: false
}
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
cwd: '/repo'
})
})
it('returns an empty graph for paths Git reports as non-repositories', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
await expect(listWorktreeGraph('/not-a-repo')).resolves.toEqual([])
})
})
describe('addWorktree', () => {
const resolveRemoteBase = () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
+36 -4
View File
@@ -457,6 +457,41 @@ async function readWorktreeList(
return parseWorktreeList(stdout)
}
async function readTranslatedWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
}
export async function listWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
return await readTranslatedWorktreeGraph(repoPath, options)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
if (isNotGitRepositoryError(err)) {
return []
}
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err)
return []
}
}
/**
* List all worktrees for a git repo at the given path.
*/
@@ -465,10 +500,7 @@ export async function listWorktrees(
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
const worktrees = await readTranslatedWorktreeGraph(repoPath, options)
return annotateSparseCheckoutStatus(worktrees)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
@@ -135,6 +135,7 @@ describe('GitHub PR local runtime routing', () => {
if (args[0] === 'pr' && args[1] === 'view') {
return {
stdout: JSON.stringify({
id: 'PR_kwDO123',
number: 7,
title: 'PR',
state: 'OPEN',
+785 -21
View File
@@ -20,6 +20,7 @@ const {
ghRepoExecOptionsMock,
githubRepoContextMock,
getSshGitProviderMock,
readLocalGitConfigSignatureMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
@@ -48,6 +49,7 @@ const {
...localGitOptions
})),
getSshGitProviderMock: vi.fn(),
readLocalGitConfigSignatureMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
@@ -90,6 +92,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('./local-git-config-signature', () => ({
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
}))
vi.mock('./rate-limit', () => ({
getRateLimit: getRateLimitMock,
rateLimitGuard: rateLimitGuardMock,
@@ -113,6 +119,7 @@ import {
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
describe('checkOrcaStarred', () => {
beforeEach(() => {
@@ -175,12 +182,15 @@ describe('getPRForBranch', () => {
ghRepoExecOptionsMock.mockClear()
githubRepoContextMock.mockClear()
getSshGitProviderMock.mockReset()
readLocalGitConfigSignatureMock.mockReset()
readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryGitCapabilityCacheForTests()
})
it('queries GitHub by head branch when the remote is on github.com', async () => {
@@ -959,7 +969,7 @@ describe('getPRForBranch', () => {
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'fork/contributor/original\n',
stdout: 'local-created-from-pr\0fork/contributor/original\n',
stderr: ''
})
ghExecFileAsyncMock
@@ -1122,14 +1132,14 @@ describe('getPRForBranch', () => {
})
})
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'origin/contributor/original\n',
stdout: 'local-created-from-pr\0origin/contributor/original\n',
stderr: ''
})
const pr = await getPRForBranch('/repo-root', 'local-created-from-pr')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--abbrev-ref', '--symbolic-full-name', 'local-created-from-pr@{upstream}'],
['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
@@ -1168,20 +1178,368 @@ describe('getPRForBranch', () => {
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockRejectedValue(
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
)
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' })
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(1)
})
it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: cannot lock ref'))
.mockResolvedValue({ stdout: 'alpha\0\nbeta\0\ngamma\0\n', stderr: '' })
await getPRForBranch('/repo-root', 'alpha')
await getPRForBranch('/repo-root', 'beta')
await getPRForBranch('/repo-root', 'gamma')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
})
it('refreshes the tracked-upstream snapshot when a branch appears inside the TTL', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'existing\0\n', stderr: '' })
.mockResolvedValueOnce({
stdout: 'existing\0\nnew-feature\0origin/contributor/original\n',
stderr: ''
})
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 78,
title: 'New branch upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/78',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 78,
title: 'Hydrated new branch upstream PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/78',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'upstream-head-oid'
})
})
await getPRForBranch('/repo-root', 'existing')
const pr = await getPRForBranch('/repo-root', 'new-feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(pr).toMatchObject({
number: 78,
title: 'Hydrated new branch upstream PR'
})
})
it('parses full local branch refs from the tracked-upstream snapshot', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0origin/contributor/original\n',
stderr: ''
})
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 80,
title: 'Ambiguous ref upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/80',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 80,
title: 'Hydrated ambiguous ref upstream PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/80',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'upstream-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature')
expect(pr).toMatchObject({
number: 80,
title: 'Hydrated ambiguous ref upstream PR'
})
})
it('parses full upstream refs from the tracked-upstream snapshot', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0refs/remotes/fork/contributor/original\n',
stderr: ''
})
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 83,
title: 'Full upstream ref PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/83',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 83,
title: 'Hydrated full upstream ref PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/83',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'upstream-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature')
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'fork', undefined)
expect(pr).toMatchObject({
number: 83,
title: 'Hydrated full upstream ref PR',
headRepo: { owner: 'fork-owner', repo: 'widgets' }
})
})
it('ignores full local-branch upstream refs from the tracked-upstream snapshot', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0refs/heads/main\n',
stderr: ''
})
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
const pr = await getPRForBranch('/repo-root', 'feature')
expect(pr).toBeNull()
expect(getOwnerRepoForRemoteMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('invalidates the local tracked-upstream snapshot when git config changes', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
readLocalGitConfigSignatureMock
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 79,
title: 'Reconfigured upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/79',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 79,
title: 'Hydrated reconfigured upstream PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/79',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'upstream-head-oid'
})
})
await getPRForBranch('/repo-root', 'feature')
const pr = await getPRForBranch('/repo-root', 'feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(pr).toMatchObject({
number: 79,
title: 'Hydrated reconfigured upstream PR'
})
})
it('does not cache positive tracked-upstream entries when config changes during the snapshot', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
readLocalGitConfigSignatureMock
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\0origin/old-upstream\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 82,
title: 'Stable config upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/82',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
await getPRForBranch('/repo-root', 'feature')
const pr = await getPRForBranch('/repo-root', 'feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(pr).toMatchObject({
number: 82,
title: 'Stable config upstream PR'
})
})
it('does not cache null tracked-upstream entries when config changes during the snapshot', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
readLocalGitConfigSignatureMock
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 85,
title: 'Unstable config upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/85',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
await getPRForBranch('/repo-root', 'feature')
const pr = await getPRForBranch('/repo-root', 'feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(pr).toMatchObject({
number: 85,
title: 'Unstable config upstream PR'
})
})
it('coalesces concurrent missing tracked-upstream probes', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
@@ -1190,7 +1548,7 @@ describe('getPRForBranch', () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockImplementation(async () => {
await Promise.resolve()
throw new Error("fatal: no upstream configured for branch 'no-pr-branch'")
return { stdout: 'no-pr-branch\0\n', stderr: '' }
})
await Promise.all([
@@ -1200,20 +1558,71 @@ describe('getPRForBranch', () => {
])
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(1)
})
it('does not cache synthetic nulls from concurrent tracked-upstream waiters', async () => {
let resolveSnapshot: (value: { stdout: string; stderr: string }) => void
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
readLocalGitConfigSignatureMock.mockResolvedValue(
'/repo-root/.git/config\u0000mtime-a\u0000100'
)
gitExecFileAsyncMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve
})
)
.mockResolvedValueOnce({ stdout: 'new-feature\0origin/contributor/original\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 84,
title: 'Concurrent waiter upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/84',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
const existingLookup = getPRForBranch('/repo-root', 'existing')
const waiterLookup = getPRForBranch('/repo-root', 'new-feature')
await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1))
resolveSnapshot!({ stdout: 'existing\0\n', stderr: '' })
const [, waiterPr] = await Promise.all([existingLookup, waiterLookup])
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(waiterPr).toMatchObject({
number: 84,
title: 'Concurrent waiter upstream PR'
})
})
it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockRejectedValue(
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
)
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' })
await getPRForBranch('/repo-root', 'no-pr-branch')
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
@@ -1225,7 +1634,7 @@ describe('getPRForBranch', () => {
})
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('no-pr-branch@{upstream}')
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' })
@@ -1245,14 +1654,14 @@ describe('getPRForBranch', () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'"))
.mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
await getPRForBranch('/repo-root', 'feature')
await vi.advanceTimersByTimeAsync(30_001)
await getPRForBranch('/repo-root', 'feature')
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('feature@{upstream}')
(args as string[]).includes('refs/heads')
)
expect(trackedUpstreamCalls).toHaveLength(2)
} finally {
@@ -1304,7 +1713,7 @@ describe('getPRForBranch', () => {
})
})
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'fork/contributor/original\n',
stdout: 'local-created-from-pr\0fork/contributor/original\n',
stderr: ''
})
@@ -1327,10 +1736,101 @@ describe('getPRForBranch', () => {
})
})
it('uses the tracked upstream remote owner when the fork branch name matches locally', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [
{ owner: 'stablyai', repo: 'orca' },
{ owner: 'origin-owner', repo: 'orca' }
],
headRepo: { owner: 'origin-owner', repo: 'orca' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'brennanb2025', repo: 'orca' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 6433,
title: 'Recover Windows worktree deletes from long paths',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/6433',
updated_at: '2026-06-26T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: {
ref: 'brennanb2025/worktree-remove-fix',
sha: 'same-name-fork-head-oid'
}
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 6433,
title: 'Recover Windows worktree deletes from long paths',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/6433',
statusCheckRollup: [],
updatedAt: '2026-06-26T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'brennanb2025/worktree-remove-fix',
baseRefOid: 'base-oid',
headRefOid: 'same-name-fork-head-oid'
})
})
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'brennanb2025/worktree-remove-fix\0brennan/brennanb2025/worktree-remove-fix\n',
stderr: ''
})
const pr = await getPRForBranch('/repo-root', 'brennanb2025/worktree-remove-fix')
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'brennan', undefined)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
[
'api',
'repos/stablyai/orca/pulls?head=brennanb2025%3Abrennanb2025%2Fworktree-remove-fix&state=all&per_page=1'
],
{ cwd: '/repo-root' }
)
expect(pr).toMatchObject({
number: 6433,
prRepo: { owner: 'stablyai', repo: 'orca' },
headRepo: { owner: 'brennanb2025', repo: 'orca' }
})
})
it('does not retry same-name tracked upstream lookup for the same head repo', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'feature/no-pr\0origin/feature/no-pr\n',
stderr: ''
})
await expect(getPRForBranch('/repo-root', 'feature/no-pr')).resolves.toBeNull()
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Fno-pr&state=all&per_page=1'],
{ cwd: '/repo-root' }
)
})
it('checks the tracked upstream branch through the SSH git provider', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'origin/contributor/original\n',
stdout: 'local-created-from-pr\0origin/contributor/original\n',
stderr: ''
})
}
@@ -1363,7 +1863,7 @@ describe('getPRForBranch', () => {
)
expect(sshGitProvider.exec).toHaveBeenCalledWith(
['rev-parse', '--abbrev-ref', '--symbolic-full-name', 'local-created-from-pr@{upstream}'],
['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'],
'/remote/repo-root'
)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
@@ -1375,6 +1875,128 @@ describe('getPRForBranch', () => {
expect(pr).toMatchObject({ number: 78, title: 'SSH upstream branch PR' })
})
it('uses the same-name tracked upstream fork owner through the SSH git provider', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'contributor/fix\0fork/contributor/fix\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [
{ owner: 'stablyai', repo: 'orca' },
{ owner: 'origin-owner', repo: 'orca' }
],
headRepo: { owner: 'origin-owner', repo: 'orca' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 79,
title: 'SSH same-name fork PR',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/79',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/fix', sha: 'same-name-ssh-head-oid' }
}
])
})
const pr = await getPRForBranch('/remote/repo-root', 'contributor/fix', undefined, 'ssh-1')
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'fork', 'ssh-1')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Ffix&state=all&per_page=1'],
{}
)
expect(pr).toMatchObject({
number: 79,
title: 'SSH same-name fork PR',
prRepo: { owner: 'stablyai', repo: 'orca' },
headRepo: { owner: 'fork-owner', repo: 'orca' }
})
})
it('caches positive tracked-upstream entries for unsigned SSH runtimes during PR refresh polling', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({
stdout: 'refs/heads/feature\0origin/contributor/original\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledTimes(1)
})
it('refreshes positive tracked-upstream entries for unsigned SSH runtimes after the TTL', async () => {
vi.useFakeTimers()
try {
const sshGitProvider = {
exec: vi
.fn()
.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0origin/old-upstream\n',
stderr: ''
})
.mockResolvedValueOnce({
stdout: 'refs/heads/feature\0origin/contributor/original\n',
stderr: ''
})
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 81,
title: 'Fresh SSH upstream PR',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/81',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
}
])
})
await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
await vi.advanceTimersByTimeAsync(30_001)
const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1')
expect(sshGitProvider.exec).toHaveBeenCalledTimes(2)
expect(pr).toMatchObject({
number: 81,
title: 'Fresh SSH upstream PR'
})
} finally {
vi.useRealTimers()
}
})
it('uses linked PR number as the source of truth when provided', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
@@ -1742,6 +2364,66 @@ describe('getPRForBranch', () => {
expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts'])
})
it('skips the unsupported merge-tree --merge-base retry after the first capability miss', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
const branchLookup = {
number: 42,
title: 'Fix PR discovery',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/42',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable_state: 'dirty',
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'feature/test', sha: 'head-oid' }
}
const exactLookup = {
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
await getPRForBranch('/repo-root', 'feature/test')
await getPRForBranch('/repo-root', 'feature/test')
const modernMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
(args as string[]).includes('--merge-base')
)
const legacyMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => {
const argv = args as string[]
return argv[0] === 'merge-tree' && !argv.includes('--merge-base')
})
expect(modernMergeTreeCalls).toHaveLength(1)
expect(legacyMergeTreeCalls).toHaveLength(2)
})
it('does not retry legacy merge-tree for older Git failures unrelated to --merge-base', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
@@ -2410,7 +3092,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', {
@@ -2427,13 +3113,27 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining([
'api',
'graphql',
'-f',
'pullRequestId=PR_kwDO123',
'-f',
'mergeMethod=SQUASH',
'-f',
'expectedHeadOid=head-oid'
]),
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
@@ -2442,6 +3142,70 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
})
it('enables auto-merge without invoking the direct merge command', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
)
).toBe(true)
expect(
ghExecFileAsyncMock.mock.calls.some(
(call) =>
call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto')
)
).toBe(false)
})
it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
cwd: '/repo-root',
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
)
).toBe(false)
})
it('blocks direct merge when GitHub reports required approval', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
+327 -57
View File
@@ -67,6 +67,7 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { readLocalGitConfigSignature } from './local-git-config-signature'
export { _resetOwnerRepoCache } from './gh-utils'
export {
getIssue,
@@ -1988,6 +1989,12 @@ const PR_LOOKUP_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_BRANCH_LIST_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
merge: 'MERGE',
squash: 'SQUASH',
rebase: 'REBASE'
}
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
acceptMergedFallbackPR?: boolean
@@ -2273,97 +2280,227 @@ type TrackedUpstreamBranch = {
branchName: string
}
const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
type TrackedUpstreamNullCacheEntry = {
type TrackedUpstreamSnapshotCacheEntry = {
expiresAt: number
gitConfigSignature?: string
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
const trackedUpstreamNullCache = new Map<string, TrackedUpstreamNullCacheEntry>()
const trackedUpstreamInFlight = new Map<string, Promise<TrackedUpstreamBranch | null>>()
type TrackedUpstreamSnapshotProbeResult = {
cacheable: boolean
gitConfigSignature?: string
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}
const trackedUpstreamSnapshotCache = new Map<string, TrackedUpstreamSnapshotCacheEntry>()
const trackedUpstreamSnapshotInFlight = new Map<
string,
Promise<TrackedUpstreamSnapshotProbeResult>
>()
const trackedUpstreamSnapshotGenerations = new Map<string, number>()
function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number {
const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1
trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration)
return nextGeneration
}
export function __resetTrackedUpstreamBranchCacheForTests(): void {
trackedUpstreamNullCache.clear()
trackedUpstreamInFlight.clear()
trackedUpstreamSnapshotCache.clear()
trackedUpstreamSnapshotInFlight.clear()
trackedUpstreamSnapshotGenerations.clear()
}
function parseTrackedUpstreamBranch(
upstreamRef: string,
branchName: string
): TrackedUpstreamBranch | null {
function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null {
const parsed = splitRemoteBranchName(upstreamRef.trim())
if (!parsed || parsed.branchName === branchName) {
if (!parsed) {
return null
}
return parsed
}
function prOwnerRepoKey(ownerRepo: OwnerRepo): string {
return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}`
}
function shouldRetryTrackedUpstreamBranch(
upstreamBranch: TrackedUpstreamBranch,
branchName: string,
upstreamHeadRepo: OwnerRepo,
headRepo: OwnerRepo | null
): boolean {
if (upstreamBranch.branchName !== branchName) {
return true
}
if (!headRepo) {
return true
}
return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo)
}
async function getTrackedUpstreamBranch(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
// Why: branches without configured upstreams are stable misses during PR
// polling; cache only nulls so positive PR discovery stays fresh.
const cacheKey = getTrackedUpstreamBranchCacheKey(
repoPath,
branchName,
connectionId,
localGitOptions
)
const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
const now = Date.now()
const cachedNull = trackedUpstreamNullCache.get(cacheKey)
if (cachedNull && cachedNull.expiresAt > now) {
return null
const cached = trackedUpstreamSnapshotCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch(
cached,
repoPath,
connectionId,
localGitOptions
)
if (
configSignatureMatches &&
cached.upstreamsByBranchName.has(branchName) &&
canUseCachedTrackedUpstreamBranch(cached, branchName)
) {
return cached.upstreamsByBranchName.get(branchName) ?? null
}
trackedUpstreamSnapshotCache.delete(cacheKey)
}
if (cachedNull) {
trackedUpstreamNullCache.delete(cacheKey)
if (cached) {
trackedUpstreamSnapshotCache.delete(cacheKey)
}
const inFlight = trackedUpstreamInFlight.get(cacheKey)
const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (inFlight) {
return inFlight
const result = await inFlight
if (result.upstreamsByBranchName.has(branchName)) {
return result.upstreamsByBranchName.get(branchName) ?? null
}
// Why: a concurrent snapshot may finish before this branch exists in git.
// Re-probe instead of returning a one-shot synthetic null.
const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
if (retryInFlight) {
const retryResult = await retryInFlight
return retryResult.upstreamsByBranchName.get(branchName) ?? null
}
}
const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions)
trackedUpstreamInFlight.set(cacheKey, probe)
// Why: PR polling can ask about hundreds of local worktree branches at once.
// Read the branch upstream snapshot in one git process per repo/runtime
// instead of spawning one failing `branch@{upstream}` probe per branch.
const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey)
const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions)
trackedUpstreamSnapshotInFlight.set(cacheKey, probe)
try {
const result = await probe
if (result) {
trackedUpstreamNullCache.delete(cacheKey)
} else {
trackedUpstreamNullCache.set(cacheKey, {
expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS
if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) {
trackedUpstreamSnapshotCache.set(cacheKey, {
...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}),
upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
})
}
return result
if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
if (fresherCached?.upstreamsByBranchName.has(branchName)) {
return fresherCached.upstreamsByBranchName.get(branchName) ?? null
}
}
return result.upstreamsByBranchName.get(branchName) ?? null
} finally {
if (trackedUpstreamInFlight.get(cacheKey) === probe) {
trackedUpstreamInFlight.delete(cacheKey)
if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
trackedUpstreamSnapshotInFlight.delete(cacheKey)
}
}
}
async function probeTrackedUpstreamSnapshot(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamSnapshotProbeResult> {
const startingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches(
repoPath,
connectionId,
localGitOptions
)
const endingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro
const configSignatureChanged =
isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature
const gitConfigSignature =
startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined
return {
// Why: transient git failures must not cache an empty snapshot that forces
// every branch lookup to delete and re-probe on the next refresh tick.
cacheable: !configSignatureChanged && !probeFailed,
probeFailed,
...(gitConfigSignature ? { gitConfigSignature } : {}),
upstreamsByBranchName
}
}
function getCacheableTrackedUpstreamSnapshot(
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
): Map<string, TrackedUpstreamBranch | null> {
// Why: SSH/WSL cannot cheaply inspect remote .git/config here. The short TTL
// still bounds stale positives, while repeated PR refreshes share one scan.
return upstreamsByBranchName
}
function canUseCachedTrackedUpstreamBranch(
cached: TrackedUpstreamSnapshotCacheEntry,
branchName: string
): boolean {
return cached.upstreamsByBranchName.has(branchName)
}
async function doesTrackedUpstreamCacheConfigSignatureMatch(
cached: TrackedUpstreamSnapshotCacheEntry,
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
if (!cached.gitConfigSignature) {
return true
}
const currentSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
return currentSignature === cached.gitConfigSignature
}
function getTrackedUpstreamBranchCacheKey(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return [runtimeKey, repoPath, branchName].join('\0')
return [runtimeKey, repoPath].join('\0')
}
async function probeTrackedUpstreamBranch(
async function probeTrackedUpstreamBranches(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<TrackedUpstreamBranch | null> {
const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`]
): Promise<{
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
}> {
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
try {
const provider = connectionId ? getSshGitProvider(connectionId) : null
const result = provider
@@ -2372,10 +2509,41 @@ async function probeTrackedUpstreamBranch(
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return parseTrackedUpstreamBranch(result.stdout, branchName)
return {
probeFailed: false,
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
}
} catch {
return { probeFailed: true, upstreamsByBranchName: new Map() }
}
}
function parseTrackedUpstreamBranches(stdout: string): Map<string, TrackedUpstreamBranch | null> {
const upstreamsByBranchName = new Map<string, TrackedUpstreamBranch | null>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const [branchName, upstreamRef] = line.split('\0')
const localBranchName = branchName?.replace(/^refs\/heads\//, '')
if (!localBranchName) {
continue
}
upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? ''))
}
return upstreamsByBranchName
}
function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null {
const remoteRefPrefix = 'refs/remotes/'
const normalizedRef = upstreamRef.trim()
if (normalizedRef.startsWith(remoteRefPrefix)) {
return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length))
}
if (normalizedRef.startsWith('refs/heads/')) {
return null
}
return parseTrackedUpstreamBranch(normalizedRef)
}
async function lookupPRByBranchName(args: {
@@ -2641,8 +2809,8 @@ export async function getPRForBranchOutcome(
data = branchLookup.data
dataRepo = branchLookup.dataRepo
if (!data) {
// Why: worktrees can have a short local branch tracking a differently
// named remote PR head; after the local miss, try that configured head.
// Why: the tracked upstream can identify the real PR head by branch
// name or by fork owner even when branch names match locally.
const upstreamBranch = await getTrackedUpstreamBranch(
repoPath,
branchName,
@@ -2657,16 +2825,21 @@ export async function getPRForBranchOutcome(
connectionId,
...localGitArgs
)) ?? headRepo
const upstreamLookup = await lookupPRByBranchName({
candidates,
headRepo: upstreamHeadRepo,
branchName: upstreamBranch.branchName,
ghOptions
})
data = upstreamLookup.data
dataRepo = upstreamLookup.dataRepo
if (data) {
dataHeadRepo = upstreamHeadRepo
if (
upstreamHeadRepo &&
shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo)
) {
const upstreamLookup = await lookupPRByBranchName({
candidates,
headRepo: upstreamHeadRepo,
branchName: upstreamBranch.branchName,
ghOptions
})
data = upstreamLookup.data
dataRepo = upstreamLookup.dataRepo
if (data) {
dataHeadRepo = upstreamHeadRepo
}
}
}
}
@@ -3751,10 +3924,10 @@ export async function setPRAutoMerge(
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
await acquire()
try {
const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto']
if (enabled) {
args.push(`--${method}`)
return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions)
}
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
@@ -3772,6 +3945,103 @@ export async function setPRAutoMerge(
}
}
type PRAutoMergeIdentity = {
id?: string
headRefOid?: string
baseRefName?: string
}
async function getPRAutoMergeIdentity(
prNumber: number,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<PRAutoMergeIdentity | null> {
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const data = JSON.parse(stdout) as PRAutoMergeIdentity
return {
id: typeof data.id === 'string' ? data.id : undefined,
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
}
}
async function runPRAutoMergeCommand(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<void> {
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
}
async function shouldUseMergeQueueAutoMerge(
pr: PRAutoMergeIdentity,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<boolean> {
if (!ownerRepo || !pr.baseRefName) {
return false
}
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
return mergeMetadata.mergeQueueRequired === true
}
async function enablePRAutoMerge(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<{ ok: true } | { ok: false; error: string }> {
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
if (!pr?.id) {
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
}
if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) {
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
return { ok: true }
}
const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) {
enablePullRequestAutoMerge(input: {
pullRequestId: $pullRequestId,
mergeMethod: $mergeMethod,
expectedHeadOid: $expectedHeadOid
}) {
pullRequest { id }
}
}`
const args = [
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`pullRequestId=${pr.id}`,
'-f',
`mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}`
]
if (pr.headRefOid) {
args.push('-f', `expectedHeadOid=${pr.headRefOid}`)
}
// Why: `gh pr merge --auto` can perform an immediate merge; this mutation
// only creates GitHub's auto-merge request and lets branch requirements gate it.
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
}
async function getPRMergeBlocker(
repoPath: string,
prNumber: number,
+37 -13
View File
@@ -5,6 +5,12 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
}
export async function getPRConflictSummary(
repoPath: string,
baseRefName: string,
@@ -117,6 +123,7 @@ async function loadConflictingFiles(
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
const capabilityKey = getMergeTreeCapabilityKey(localGitOptions)
const modernArgs = [
'merge-tree',
'--write-tree',
@@ -138,6 +145,10 @@ async function loadConflictingFiles(
baseOid
]
if (mergeTreeMergeBaseUnsupportedRuntimes.has(capabilityKey)) {
return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions)
}
try {
const result = await gitExecFileAsync(modernArgs, {
cwd: repoPath,
@@ -157,22 +168,35 @@ async function loadConflictingFiles(
throw error
}
try {
const result = await gitExecFileAsync(legacyArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (fallbackError) {
const fallbackStdout = getGitErrorOutput(fallbackError, 'stdout')
if (fallbackStdout) {
return parseMergeTreeNameOnlyOutput(fallbackStdout)
}
throw fallbackError
}
mergeTreeMergeBaseUnsupportedRuntimes.add(capabilityKey)
return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions)
}
}
async function loadConflictingFilesWithLegacyMergeTree(
repoPath: string,
legacyArgs: string[],
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
try {
const result = await gitExecFileAsync(legacyArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (fallbackError) {
const fallbackStdout = getGitErrorOutput(fallbackError, 'stdout')
if (fallbackStdout) {
return parseMergeTreeNameOnlyOutput(fallbackStdout)
}
throw fallbackError
}
}
function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string {
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host'
}
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {
+366
View File
@@ -1,3 +1,6 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
@@ -27,12 +30,17 @@ import {
resolvePRRepositoryCandidates,
resolveIssueSource
} from './gh-utils'
import {
__resetLocalGitConfigSignatureCacheForTests,
readLocalGitConfigSignature
} from './local-git-config-signature'
describe('github owner/repo resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
__resetLocalGitConfigSignatureCacheForTests()
})
it('parses GitHub HTTPS and SSH remotes', () => {
@@ -289,6 +297,364 @@ describe('github owner/repo resolution', () => {
vi.useRealTimers()
}
})
it('keeps local missing-remote probes cached beyond the short positive TTL', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock.mockRejectedValue(new Error("error: No such remote 'origin'"))
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('treats stderr-only missing-remote errors as stable negatives', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('Command failed'), {
stderr: "fatal: No such remote 'origin'"
})
)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('does not apply the long negative TTL when git remote get-url fails transiently', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: cannot lock ref'))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('invalidates a cached local missing remote when git config changes', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const configPath = join(repoPath, '.git', 'config')
await writeFile(configPath, '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
configPath,
'[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('invalidates a cached local missing remote when an included git config changes', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'remote.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = ${includedConfigPath}\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks included git config paths with inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'remote-with-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = ${includedConfigPath} # origin remote lives here\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks included git config paths when section headers have inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'section-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include] # comment\n\tpath = ${includedConfigPath}\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks quoted included git config paths with inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'quoted-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}" # comment\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks quoted included git config paths with comment characters in the path', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includeDir = join(repoPath, 'include # hash')
await mkdir(includeDir)
const includedConfigPath = join(includeDir, 'remote.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}"\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('includes per-worktree git config in local config signatures', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const gitDir = join(repoPath, '.git')
await mkdir(gitDir)
await writeFile(join(gitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
await writeFile(
join(gitDir, 'config.worktree'),
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('includes linked worktree config in local config signatures', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const commonGitDir = join(repoPath, 'common-git')
const worktreeGitDir = join(commonGitDir, 'worktrees', 'feature')
const worktreePath = join(repoPath, 'feature-worktree')
await mkdir(worktreeGitDir, { recursive: true })
await mkdir(worktreePath)
await writeFile(join(worktreePath, '.git'), `gitdir: ${worktreeGitDir}\n`)
await writeFile(join(worktreeGitDir, 'commondir'), '../..\n')
await writeFile(join(commonGitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath: worktreePath,
connectionId: null
})
await writeFile(
join(worktreeGitDir, 'config.worktree'),
'[branch "feature"]\n\tremote = origin\n\tmerge = refs/heads/contributor/original\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath: worktreePath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks includeIf paths with comment markers inside quoted section headers', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const gitDir = join(repoPath, '.git')
const includedDir = join(repoPath, 'Work #1')
const includedConfigPath = join(includedDir, 'included.gitconfig')
await mkdir(gitDir)
await mkdir(includedDir)
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
await writeFile(
join(gitDir, 'config'),
`[includeIf "gitdir:${includedDir}/"]\n\tpath = "${includedConfigPath}"\n`
)
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/renamed-widgets.git\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
})
describe('resolveIssueSource', () => {
@@ -0,0 +1,49 @@
import type { GitHubOwnerRepo } from '../../shared/types'
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
function normalizeGitHubRemoteHost(host: string): string {
const normalizedHost = host.toLowerCase()
// Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos.
return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost
}
function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner' | 'repo'> | null {
const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
if (parts.length !== 2) {
return null
}
const [owner, repoWithSuffix] = parts
const repo = repoWithSuffix.replace(/\.git$/i, '')
if (!owner || !repo) {
return null
}
return { owner, repo }
}
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
const trimmed = remoteUrl.trim()
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
if (sshMatch) {
return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] }
}
try {
const url = new URL(trimmed)
if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
const path = parseGitHubRemotePath(url.pathname)
return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null
} catch {
return null
}
}
export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null {
const identity = parseGitHubRemoteIdentity(remoteUrl)
if (!identity || identity.host.toLowerCase() !== 'github.com') {
return null
}
return { owner: identity.owner, repo: identity.repo }
}
+56 -55
View File
@@ -1,10 +1,18 @@
import { gitExecFileAsync } from '../git/runner'
import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { readLocalGitConfigSignature } from './local-git-config-signature'
import {
parseGitHubOwnerRepo,
parseGitHubRemoteIdentity,
type GitHubRemoteIdentity
} from './github-remote-identity-parsing'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
export type OwnerRepo = GitHubOwnerRepo
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
export type { GitHubRemoteIdentity }
export { parseGitHubOwnerRepo, parseGitHubRemoteIdentity }
export type GitHubRepoContext = {
repoPath: string
@@ -41,12 +49,14 @@ export function ghRepoExecOptions(context: GitHubRepoContext): {
}
}
const OWNER_REPO_CACHE_TTL_MS = 30_000
const OWNER_REPO_POSITIVE_CACHE_TTL_MS = 30_000
const OWNER_REPO_NEGATIVE_CACHE_TTL_MS = 5 * 60_000
const OWNER_REPO_CACHE_MAX_ENTRIES = 512
type OwnerRepoCacheEntry = {
value: OwnerRepo | null
expiresAt: number
configSignature?: string
}
const ownerRepoCache = new Map<string, OwnerRepoCacheEntry>()
@@ -78,52 +88,6 @@ function pruneOwnerRepoCache(now: number): void {
}
}
export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null {
const identity = parseGitHubRemoteIdentity(remoteUrl)
if (!identity || identity.host.toLowerCase() !== 'github.com') {
return null
}
return { owner: identity.owner, repo: identity.repo }
}
function normalizeGitHubRemoteHost(host: string): string {
const normalizedHost = host.toLowerCase()
// Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos.
return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost
}
function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner' | 'repo'> | null {
const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
if (parts.length !== 2) {
return null
}
const [owner, repoWithSuffix] = parts
const repo = repoWithSuffix.replace(/\.git$/i, '')
if (!owner || !repo) {
return null
}
return { owner, repo }
}
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
const trimmed = remoteUrl.trim()
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
if (sshMatch) {
return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] }
}
try {
const url = new URL(trimmed)
if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
const path = parseGitHubRemotePath(url.pathname)
return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null
} catch {
return null
}
}
export async function getRemoteUrlForRepo(
context: GitHubRepoContext,
remoteName: string
@@ -143,6 +107,13 @@ export async function getRemoteUrlForRepo(
return stdout
}
function getOwnerRepoCacheTtl(value: OwnerRepo | null, configSignature?: string): number {
if (value) {
return OWNER_REPO_POSITIVE_CACHE_TTL_MS
}
return configSignature ? OWNER_REPO_NEGATIVE_CACHE_TTL_MS : OWNER_REPO_POSITIVE_CACHE_TTL_MS
}
export async function getOwnerRepoForRemote(
repoPath: string,
remoteName: string,
@@ -156,7 +127,26 @@ export async function getOwnerRepoForRemote(
pruneOwnerRepoCache(now)
const cached = ownerRepoCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return cached.value
if (cached.value === null && cached.configSignature !== undefined) {
const currentSignature = await readLocalGitConfigSignature(context)
if (currentSignature !== cached.configSignature) {
ownerRepoCache.delete(cacheKey)
} else {
return cached.value
}
} else {
return cached.value
}
}
if (cached && cached.expiresAt <= now) {
ownerRepoCache.delete(cacheKey)
}
const nextConfigSignature = await readLocalGitConfigSignature(context)
const refreshedNow = Date.now()
const refreshedCached = ownerRepoCache.get(cacheKey)
if (refreshedCached && refreshedCached.expiresAt > refreshedNow) {
return refreshedCached.value
}
const inFlight = ownerRepoInFlight.get(cacheKey)
@@ -166,7 +156,7 @@ export async function getOwnerRepoForRemote(
// Why: startup can resolve issue sources, PR candidates, and repo metadata
// for the same repo concurrently. Coalesce missing-remote probes.
const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey)
const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature)
ownerRepoInFlight.set(cacheKey, probe)
try {
return await probe
@@ -180,7 +170,8 @@ export async function getOwnerRepoForRemote(
async function resolveOwnerRepoForRemote(
context: GitHubRepoContext,
remoteName: string,
cacheKey: string
cacheKey: string,
configSignature?: string
): Promise<OwnerRepo | null> {
const now = Date.now()
try {
@@ -189,15 +180,25 @@ async function resolveOwnerRepoForRemote(
if (result) {
ownerRepoCache.set(cacheKey, {
value: result,
expiresAt: now + OWNER_REPO_CACHE_TTL_MS
expiresAt: now + getOwnerRepoCacheTtl(result, configSignature)
})
pruneOwnerRepoCache(now)
return result
}
} catch {
// ignore - non-GitHub remote or no remote
} catch (error) {
// Why: only stable "no such remote" misses are safe to hold for minutes.
// Transient git lock/IO failures must retry on the next lookup.
if (!isStableMissingGitRemoteError(error)) {
return null
}
}
ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + OWNER_REPO_CACHE_TTL_MS })
// Why: a missing/non-GitHub remote is stable until `.git/config` changes.
// Holding that negative longer avoids Git process churn across PR polling.
ownerRepoCache.set(cacheKey, {
value: null,
expiresAt: now + getOwnerRepoCacheTtl(null, configSignature),
...(configSignature ? { configSignature } : {})
})
pruneOwnerRepoCache(now)
return null
}
@@ -0,0 +1,239 @@
import { readFile, stat } from 'fs/promises'
import { homedir } from 'os'
import { dirname, isAbsolute, join, resolve } from 'path'
import type { GitHubRepoContext } from './github-repository-identity'
type LocalGitConfigPaths = {
commonConfigPath: string
worktreeConfigPath: string
}
const localGitConfigSignatureInFlight = new Map<string, Promise<string | undefined>>()
export async function readLocalGitConfigSignature(
context: GitHubRepoContext
): Promise<string | undefined> {
if (context.connectionId || context.wslDistro) {
// Why: this signature only covers host filesystem config files; remote
// runtimes are already separated by cache key and probed through git.
return undefined
}
const cacheKey = context.repoPath
const inFlight = localGitConfigSignatureInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
const read = readUncachedLocalGitConfigSignature(context.repoPath)
localGitConfigSignatureInFlight.set(cacheKey, read)
try {
return await read
} finally {
if (localGitConfigSignatureInFlight.get(cacheKey) === read) {
localGitConfigSignatureInFlight.delete(cacheKey)
}
}
}
export function __resetLocalGitConfigSignatureCacheForTests(): void {
localGitConfigSignatureInFlight.clear()
}
async function readUncachedLocalGitConfigSignature(repoPath: string): Promise<string | undefined> {
const configPaths = await resolveLocalGitConfigPaths(repoPath)
if (!configPaths) {
return undefined
}
const signatures = await Promise.all([
readConfigPathSignatures(configPaths.commonConfigPath),
readConfigPathSignatures(configPaths.worktreeConfigPath)
])
return signatures.flat().join('\0')
}
async function readConfigPathSignatures(
configPath: string,
visited = new Set<string>()
): Promise<string[]> {
if (visited.has(configPath)) {
return []
}
visited.add(configPath)
const ownSignature = await readConfigPathSignature(configPath)
let configText: string
try {
configText = await readFile(configPath, 'utf8')
} catch {
return [ownSignature]
}
const includedPaths = parseIncludedConfigPaths(configText, dirname(configPath))
const includedSignatures = await Promise.all(
includedPaths.map((includedPath) => readConfigPathSignatures(includedPath, visited))
)
return [ownSignature, ...includedSignatures.flat()]
}
async function readConfigPathSignature(configPath: string): Promise<string> {
try {
const stats = await stat(configPath)
return `${configPath}\0${stats.mtimeMs}\0${stats.size}`
} catch {
return `${configPath}\0missing`
}
}
function parseIncludedConfigPaths(configText: string, baseDir: string): string[] {
const includedPaths: string[] = []
let inIncludeSection = false
for (const rawLine of configText.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#') || line.startsWith(';')) {
continue
}
const sectionName = parseConfigSectionName(line)
if (sectionName) {
inIncludeSection = sectionName === 'include' || sectionName.startsWith('includeif ')
continue
}
if (!inIncludeSection) {
continue
}
const includePath = parseIncludedConfigPath(line)
if (includePath) {
includedPaths.push(resolveIncludedConfigPath(includePath, baseDir))
}
}
return includedPaths
}
function parseConfigSectionName(line: string): string | null {
if (!line.startsWith('[')) {
return null
}
let quote: string | null = null
for (let index = 1; index < line.length; index += 1) {
const char = line[index]
if (quote) {
if (char === quote) {
quote = null
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char !== ']') {
continue
}
const trailing = line.slice(index + 1).trim()
if (trailing && !trailing.startsWith('#') && !trailing.startsWith(';')) {
return null
}
return line.slice(1, index).trim().toLowerCase()
}
return null
}
function parseIncludedConfigPath(line: string): string | null {
const match = line.match(/^path\s*=\s*(.+)$/i)
if (!match) {
return null
}
const rawValue = match[1].trim()
if (!rawValue) {
return null
}
const quotedValue = parseQuotedConfigValue(rawValue)
if (quotedValue !== null) {
return quotedValue
}
const value = stripInlineConfigComment(rawValue).trim()
if (!value) {
return null
}
return value
}
function parseQuotedConfigValue(rawValue: string): string | null {
const quote = rawValue[0]
if (quote !== '"' && quote !== "'") {
return null
}
const endQuoteIndex = rawValue.indexOf(quote, 1)
if (endQuoteIndex === -1) {
return null
}
const trailing = rawValue.slice(endQuoteIndex + 1).trim()
if (trailing && !trailing.startsWith('#') && !trailing.startsWith(';')) {
return null
}
return rawValue.slice(1, endQuoteIndex)
}
function stripInlineConfigComment(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1)
}
return value.replace(/\s[#;].*$/, '').trim()
}
function resolveIncludedConfigPath(includePath: string, baseDir: string): string {
if (includePath === '~') {
return homedir()
}
if (includePath.startsWith('~/')) {
return join(homedir(), includePath.slice(2))
}
if (isAbsolute(includePath)) {
return includePath
}
return resolve(baseDir, includePath)
}
async function resolveLocalGitConfigPaths(repoPath: string): Promise<LocalGitConfigPaths | null> {
const dotGitPath = join(repoPath, '.git')
try {
const dotGitStats = await stat(dotGitPath)
if (dotGitStats.isDirectory()) {
return {
commonConfigPath: join(dotGitPath, 'config'),
worktreeConfigPath: join(dotGitPath, 'config.worktree')
}
}
if (!dotGitStats.isFile()) {
return null
}
} catch {
return null
}
try {
const gitFile = await readFile(dotGitPath, 'utf8')
const match = gitFile.match(/^gitdir:\s*(.+?)\s*$/im)
if (!match) {
return null
}
const gitDir = resolve(dirname(dotGitPath), match[1])
let commonGitDir = gitDir
try {
const commonDir = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim()
if (commonDir) {
commonGitDir = resolve(gitDir, commonDir)
}
} catch {
// Fall back to the linked worktree gitdir below.
}
return {
commonConfigPath: join(commonGitDir, 'config'),
worktreeConfigPath: join(gitDir, 'config.worktree')
}
} catch {
return null
}
}
@@ -184,6 +184,326 @@ describe('pr-refresh-coordinator', () => {
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('paces a burst of distinct active refreshes', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
expect(
getPRForBranchOutcomeMock.mock.calls.map(([repoPath, branch]) => [repoPath, branch])
).toEqual([
['/repo', 'feature/9'],
['/repo', 'feature/8'],
['/repo', 'feature/7']
])
await vi.advanceTimersByTimeAsync(29_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
expect(
sendMock.mock.calls
.map(([, event]) => event)
.some((event) => event.reason === 'active' && event.status === 'queued')
).toBe(true)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(6)
})
it('treats a same-key active reactivation as the latest active signal', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh(
makeCandidate({
cacheKey: '/repo::feature/0',
branch: 'feature/0',
worktreeId: 'wt-0'
}),
'active',
80,
1
)
await vi.advanceTimersByTimeAsync(30_000)
expect(getPRForBranchOutcomeMock.mock.calls[3]?.[1]).toBe('feature/0')
})
it('does not let one capped active window block another window', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
enqueuePRRefresh(
makeCandidate({
cacheKey: '/repo::feature/other-window',
branch: 'feature/other-window',
worktreeId: 'wt-other-window'
}),
'active',
80,
2
)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain(
'feature/other-window'
)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4)
})
it('does not let capped host active work block WSL or SSH active scopes', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
enqueuePRRefresh(
makeCandidate({
cacheKey: 'wsl::repo-1::feature/wsl',
branch: 'feature/wsl',
localGitOptions: { wslDistro: 'Ubuntu' },
worktreeId: 'wt-wsl'
}),
'active',
80,
1
)
enqueuePRRefresh(
makeCandidate({
cacheKey: 'ssh:ssh-1::repo-1::feature/ssh',
branch: 'feature/ssh',
connectionId: 'ssh-1',
worktreeId: 'wt-ssh'
}),
'active',
80,
1
)
await vi.advanceTimersByTimeAsync(0)
const startedBranches = getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])
expect(startedBranches).toContain('feature/wsl')
expect(startedBranches).toContain('feature/ssh')
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
})
it('does not let a capped active scope block ready visible work', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: '/repo::feature/visible',
branch: 'feature/visible',
worktreeId: 'wt-visible'
})
],
1,
1
)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain('feature/visible')
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4)
})
it('wakes for visible budget spacing before a capped active burst opens', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: '/repo::feature/visible-first',
branch: 'feature/visible-first',
worktreeId: 'wt-visible-first'
})
],
1,
1
)
await vi.advanceTimersByTimeAsync(0)
for (let index = 0; index < 10; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: '/repo::feature/visible-second',
branch: 'feature/visible-second',
worktreeId: 'wt-visible-second'
})
],
2,
1
)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(9_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toContain(
'feature/visible-second'
)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
})
it('does not consume active burst slots for rate-limit pauses', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getRateLimitMock
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false })
.mockResolvedValue({ ok: true })
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
message: 'missing upstream',
fetchedAt: Date.now()
})
for (let index = 0; index < 6; index += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`
}),
'active',
80,
1
)
}
await vi.advanceTimersByTimeAsync(0)
const pausedEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit')
expect(pausedEvents).toHaveLength(3)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual([
'feature/2',
'feature/1',
'feature/0'
])
})
it('preserves an active refresh queued while a visible refresh is in flight', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
+137 -13
View File
@@ -21,7 +21,9 @@ type QueueEntry = {
reason: GitHubPRRefreshReason
priority: number
dueAt: number
queuedAt: number
bypassBackgroundBudget?: boolean
activeDelayNotified?: boolean
windowId?: number
}
@@ -66,12 +68,16 @@ const POST_PUSH_DELAY_MS = 2_500
const BACKOFF_BASE_MS = 60_000
const BACKOFF_MAX_MS = 15 * 60_000
const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000
const ACTIVE_BURST_WINDOW_MS = 30_000
const ACTIVE_BURST_MAX = 3
let sequence = 0
let queueOrder = 0
let draining = false
let drainTimer: ReturnType<typeof setTimeout> | null = null
const queue = new Map<string, QueueEntry>()
const backgroundStarts: number[] = []
const activeStartsByScope = new Map<string, number[]>()
const errorBackoff = new Map<string, { failures: number; retryAt: number }>()
let lastBackgroundStartAt = 0
const visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
@@ -123,13 +129,23 @@ function recordPRRefreshQueueDiagnostic(
})
}
function clearActiveBurstWindow(windowId: number): void {
const windowPrefix = `${windowId}::`
for (const scope of Array.from(activeStartsByScope.keys())) {
if (scope.startsWith(windowPrefix)) {
activeStartsByScope.delete(scope)
}
}
}
export function clearVisiblePRRefreshWindow(windowId: number): void {
if (!visibleByWindow.delete(windowId)) {
return
const hadVisibleRefreshes = visibleByWindow.delete(windowId)
clearActiveBurstWindow(windowId)
if (hadVisibleRefreshes) {
// Why: visible follow-ups are owned by the renderer that reported them.
// If that WebContents is destroyed, no later visibility report may arrive.
removeInvisibleVisibleRefreshes()
}
// Why: visible follow-ups are owned by the renderer that reported them.
// If that WebContents is destroyed, no later visibility report may arrive.
removeInvisibleVisibleRefreshes()
}
function nextSequence(): number {
@@ -137,6 +153,11 @@ function nextSequence(): number {
return sequence
}
function nextQueueOrder(): number {
queueOrder += 1
return queueOrder
}
function broadcast(event: Omit<GitHubPRRefreshEvent, 'sequence'>, sequenceOverride?: number): void {
const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent
for (const wc of webContents.getAllWebContents()) {
@@ -358,6 +379,7 @@ function scheduleVisibleFollowUp(
reason: 'visible',
priority,
dueAt: retryAt,
queuedAt: nextQueueOrder(),
windowId
})
// Why: this is a delayed retry, not active work; showing it as a spinner
@@ -386,6 +408,7 @@ function scheduleVisibleFollowUp(
reason: 'visible',
priority,
dueAt,
queuedAt: nextQueueOrder(),
// Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible
// spacing would otherwise delay it past the intended prompt retry window.
bypassBackgroundBudget: pendingMergeabilityDueAt !== null,
@@ -468,6 +491,80 @@ function nextBudgetDelay(): number {
return Math.max(spacingDelay, windowDelay)
}
function activeBurstScope(entry: QueueEntry): string {
const runtimeScope = entry.candidate.connectionId
? `ssh:${entry.candidate.connectionId}`
: `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}`
return `${entry.windowId ?? 'global'}::${runtimeScope}`
}
function pruneActiveStarts(scope: string, now: number): number[] {
const activeStarts = activeStartsByScope.get(scope) ?? []
while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) {
activeStarts.shift()
}
if (activeStarts.length === 0) {
activeStartsByScope.delete(scope)
} else {
activeStartsByScope.set(scope, activeStarts)
}
return activeStarts
}
function nextActiveBurstDelay(entry: QueueEntry): number {
const now = Date.now()
const activeStarts = pruneActiveStarts(activeBurstScope(entry), now)
if (activeStarts.length < ACTIVE_BURST_MAX) {
return 0
}
return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0]))
}
function noteActiveStart(entry: QueueEntry): void {
const now = Date.now()
const scope = activeBurstScope(entry)
const activeStarts = pruneActiveStarts(scope, now)
activeStarts.push(now)
activeStartsByScope.set(scope, activeStarts)
}
function activeOrder(a: QueueEntry, b: QueueEntry): number {
if (a.reason !== 'active' || b.reason !== 'active') {
return 0
}
if (activeBurstScope(a) !== activeBurstScope(b)) {
return 0
}
// Why: activation churn should refresh the worktree the user lands on before
// stale transient selections from the same window/runtime scope.
return b.queuedAt - a.queuedAt
}
function entryDelay(entry: QueueEntry): number {
const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0
if (activeBurstDelay > 0) {
return activeBurstDelay
}
return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0
}
function isActiveBurstDelayed(entry: QueueEntry): boolean {
return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0
}
function nextQueuedWakeDelay(excludedKey: string): number | null {
const now = Date.now()
let nextDelay = Number.POSITIVE_INFINITY
for (const entry of queue.values()) {
if (entry.key === excludedKey) {
continue
}
const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry)
nextDelay = Math.min(nextDelay, delay)
}
return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null
}
function scheduleDrain(delay = 0): void {
if (drainTimer) {
clearTimeout(drainTimer)
@@ -484,7 +581,7 @@ function queuedEntriesByPriority(): QueueEntry[] {
const aReady = a.dueAt <= now
const bReady = b.dueAt <= now
if (aReady && bReady) {
return b.priority - a.priority || a.dueAt - b.dueAt
return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt
}
if (aReady !== bReady) {
return aReady ? -1 : 1
@@ -500,19 +597,37 @@ async function drainQueue(): Promise<void> {
draining = true
try {
while (queue.size > 0) {
const next = queuedEntriesByPriority()[0]
let next = queuedEntriesByPriority()[0]
const waitMs = next.dueAt - Date.now()
if (waitMs > 0) {
scheduleDrain(waitMs)
return
}
const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0
if (budgetDelay > 0) {
diagnosticsCounters.backgroundPauses += 1
recordPRRefreshQueueDiagnostic('background-pause', next.reason)
scheduleDrain(budgetDelay)
return
let delay = entryDelay(next)
if (delay > 0) {
const runnable = queuedEntriesByPriority().find(
(entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0
)
if (runnable && runnable.key !== next.key) {
next = runnable
delay = 0
} else {
if (isActiveBurstDelayed(next) && !next.activeDelayNotified) {
next.activeDelayNotified = true
broadcast({
aliases: Array.from(next.aliases.values()),
reason: next.reason,
status: 'queued'
})
}
if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) {
diagnosticsCounters.backgroundPauses += 1
recordPRRefreshQueueDiagnostic('background-pause', next.reason)
}
scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay))
return
}
}
queue.delete(next.key)
@@ -571,6 +686,11 @@ async function drainQueue(): Promise<void> {
if (isBudgetedQueueEntry(next)) {
noteBackgroundStart()
}
if (next.reason === 'active') {
// Why: activation is high priority, but tab/worktree churn can enqueue
// many distinct active refreshes that each probe local Git.
noteActiveStart(next)
}
for (const bucket of buckets) {
noteRateLimitSpend(bucket)
}
@@ -632,11 +752,14 @@ export function enqueuePRRefresh(
const shouldPromoteExisting =
priority > existing.priority ||
isManual(reason) ||
(reason === 'active' && existing.reason === 'active') ||
(priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason))
if (shouldPromoteExisting) {
existing.priority = priority
existing.reason = reason
existing.dueAt = Math.min(existing.dueAt, dueAt)
existing.queuedAt = nextQueueOrder()
existing.activeDelayNotified = false
existing.candidate = candidate
existing.windowId = windowId ?? existing.windowId
}
@@ -650,6 +773,7 @@ export function enqueuePRRefresh(
reason,
priority,
dueAt,
queuedAt: nextQueueOrder(),
windowId
})
}
@@ -0,0 +1,16 @@
export function isStableMissingGitRemoteError(error: unknown): boolean {
const parts: string[] = []
if (error instanceof Error) {
parts.push(error.message)
}
if (typeof error === 'object' && error !== null && 'stderr' in error) {
const stderr = (error as { stderr?: unknown }).stderr
if (typeof stderr === 'string') {
parts.push(stderr)
}
}
if (parts.length === 0) {
parts.push(String(error))
}
return /no such remote/i.test(parts.join('\n'))
}
+234 -61
View File
@@ -81,58 +81,215 @@ describe('getWorkItemDetails', () => {
acquireMock.mockResolvedValue(undefined)
})
it('uses the collapsed GraphQL issue query as the hot path', async () => {
it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => {
getWorkItemMock.mockResolvedValueOnce({
id: 'issue:923',
type: 'issue',
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [{ login: 'jinjing' }] },
participants: {
nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }]
},
comments: {
nodes: [
{
databaseId: 7,
body: 'first',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7',
author: { login: 'octocat', avatarUrl: 'https://x/y' }
}
]
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
const timelineEvents = [
{
id: 101,
event: 'assigned',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
assignee: { login: 'assigned-user' },
created_at: '2026-04-01T01:00:00Z'
},
{
id: 102,
event: 'cross-referenced',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
created_at: '2026-04-01T02:00:00Z',
source: {
issue: {
number: 6180,
title: 'Synthetic reference PR',
html_url: 'https://github.com/acme/widgets/pull/6180',
repository: { owner: { login: 'acme' }, name: 'widgets' },
pull_request: {}
}
}
},
{
id: 103,
event: 'moved_columns_in_project',
actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' },
created_at: '2026-04-01T03:00:00Z',
previous_column_name: 'Doing',
project_column_name: 'Complete',
project: { name: 'Example Project' }
},
{
id: 104,
event: 'closed',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
created_at: '2026-04-01T04:00:00Z',
state_reason: 'completed',
closer: {
number: 6180,
title: 'Synthetic reference PR',
html_url: 'https://github.com/acme/widgets/pull/6180',
repository: { owner: { login: 'acme' }, name: 'widgets' },
pull_request: {}
}
}
]
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [{ login: 'assigned-user' }] },
participants: {
nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }]
},
comments: {
nodes: [
{
databaseId: 7,
body: 'first',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://github.com/acme/widgets/issues/923#issuecomment-7',
author: { login: 'issue-author', avatarUrl: 'https://x/y' }
}
]
}
}
}
}
}
})
})
.mockResolvedValueOnce({
stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n')
})
})
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined)
// Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api')
expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql')
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'--cache',
'60s',
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
'--jq',
'.[] | @json'
])
expect(details?.body).toBe('Issue body')
expect(details?.assignees).toEqual(['jinjing'])
expect(details?.assignees).toEqual(['assigned-user'])
expect(details?.comments).toHaveLength(1)
expect(details?.comments[0].id).toBe(7)
expect(details?.participants?.[0]?.login).toBe('octocat')
expect(details?.timelineItems).toMatchObject([
{ event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' },
{
event: 'cross-referenced',
source: {
type: 'pr',
number: 6180,
repository: 'acme/widgets'
}
},
{
event: 'moved_columns_in_project',
actor: 'github-project-automation',
previousColumnName: 'Doing',
columnName: 'Complete',
projectName: 'Example Project'
},
{
event: 'closed',
stateReason: 'completed',
closer: { type: 'pr', number: 6180 }
}
])
expect(details?.participants?.[0]?.login).toBe('issue-author')
})
it('caps issue timeline pagination by supported activity items', async () => {
getWorkItemMock.mockResolvedValueOnce({
id: 'issue:923',
type: 'issue',
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
const makeTimelineEvent = (page: number, index: number, event: string): string =>
JSON.stringify({
id: `${page}:${index}`,
event,
actor: { login: 'issue-author', avatar_url: 'https://x/y' },
assignee: { login: `assignee-${page}-${index}` },
created_at: '2026-04-01T00:00:00Z'
})
const makeTimelinePage = (page: number, supportedCount: number): string =>
Array.from({ length: 100 }, (_, index) =>
makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed')
).join('\n')
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
}
}
}
})
})
.mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) })
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7)
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1'
)
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=2'
)
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=3'
)
expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=6'
)
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
)
).toBe(false)
const timelineItems = details?.timelineItems
if (!timelineItems) {
throw new Error('Expected timeline items to be present')
}
expect(timelineItems).toHaveLength(300)
expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
})
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
@@ -142,17 +299,18 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
// Collapsed GraphQL throws → fallback path picks up.
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL error'))
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: { repository: { issue: { participants: { nodes: [] } } } }
@@ -166,12 +324,24 @@ describe('getWorkItemDetails', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'],
['api', '--cache', '60s', 'repos/acme/widgets/issues/923'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'],
['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
4,
[
'api',
'--cache',
'60s',
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
'--jq',
'.[] | @json'
],
{ cwd: '/repo-root' }
)
expect(details?.body).toBe('Issue body')
@@ -190,19 +360,20 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false)
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
expect(details?.body).toBe('Issue body')
@@ -216,31 +387,33 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Remote issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Remote issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
}
}
}
}
})
})
})
.mockResolvedValueOnce({ stdout: '[]' })
const details = await getWorkItemDetails('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
expect(getWorkItemMock).toHaveBeenCalledWith('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2')
expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1')
expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({})
expect(details?.body).toBe('Remote issue body')
})
@@ -253,17 +426,17 @@ describe('getWorkItemDetails', () => {
number: 42,
title: 'Review drawer WSL',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/42',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'pr-author'
})
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getPRCommentsMock.mockResolvedValue([])
getPRChecksMock.mockResolvedValue([])
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
const target = args.at(-1)
if (target === 'repos/stablyai/orca/pulls/42') {
if (target === 'repos/acme/widgets/pulls/42') {
return {
stdout: JSON.stringify({
body: 'PR body',
@@ -272,7 +445,7 @@ describe('getWorkItemDetails', () => {
})
}
}
if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') {
if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') {
return { stdout: '[]' }
}
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
+218 -10
View File
@@ -6,6 +6,8 @@ import type {
GitHubPRFile,
GitHubPRFileContents,
GitHubPRFileViewedState,
GitHubIssueTimelineItem,
GitHubIssueTimelineTarget,
GitHubWorkItem,
GitHubWorkItemDetails,
PRCheckDetail,
@@ -30,6 +32,10 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow'
// at 100 per page; we cap at a reasonable total so a massive PR cannot starve
// the gh semaphore while we fetch file listings.
const MAX_PR_FILES = 300
// Why: issue timelines can be extremely noisy from automation and cross-links.
// Bound drawer detail work so one huge issue cannot monopolize gh/API time.
const MAX_ISSUE_TIMELINE_ITEMS = 300
const GITHUB_REST_PAGE_SIZE = 100
// Why: hosted PR files must exceed the renderer's large-diff threshold before
// we give up on the raw fetch; otherwise the UI sees an empty diff instead of
// the safety fallback.
@@ -126,6 +132,197 @@ type GraphQLIssueDetailsResponse = {
errors?: { message?: string }[]
}
type GitHubOwnerRepoSlug = { owner: string; repo: string }
type RestTimelineUser = {
login?: string | null
avatar_url?: string | null
}
type RestTimelineIssue = {
number?: number | null
title?: string | null
html_url?: string | null
repository?: {
name?: string | null
owner?: { login?: string | null } | null
} | null
pull_request?: unknown
}
type RestTimelineEvent = {
id?: number | string | null
node_id?: string | null
event?: string | null
actor?: RestTimelineUser | null
user?: RestTimelineUser | null
assignee?: RestTimelineUser | null
created_at?: string | null
source?: {
issue?: RestTimelineIssue | null
} | null
closer?: RestTimelineIssue | null
state_reason?: string | null
project_card?: {
column_name?: string | null
previous_column_name?: string | null
project_url?: string | null
} | null
project?: {
name?: string | null
} | null
project_column_name?: string | null
previous_column_name?: string | null
}
function isSupportedTimelineEvent(
eventName: string | null | undefined
): eventName is GitHubIssueTimelineItem['event'] {
return (
eventName === 'assigned' ||
eventName === 'unassigned' ||
eventName === 'mentioned' ||
eventName === 'cross-referenced' ||
eventName === 'closed' ||
eventName === 'reopened' ||
eventName === 'moved_columns_in_project'
)
}
function mapTimelineTarget(
issue: RestTimelineIssue | null | undefined
): GitHubIssueTimelineTarget | undefined {
if (!issue || typeof issue.number !== 'number' || !issue.html_url) {
return undefined
}
const owner = issue.repository?.owner?.login
const repo = issue.repository?.name
return {
type: issue.pull_request ? 'pr' : 'issue',
number: issue.number,
title: issue.title ?? '',
url: issue.html_url,
repository: owner && repo ? `${owner}/${repo}` : undefined
}
}
function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } {
const actor = event.actor ?? event.user
return {
login: actor?.login ?? 'ghost',
avatarUrl: actor?.avatar_url ?? ''
}
}
function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null {
const eventName = event.event
if (!isSupportedTimelineEvent(eventName)) {
return null
}
if (!event.created_at) {
return null
}
const actor = getTimelineActor(event)
const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`)
const base = {
id,
event: eventName,
actor: actor.login,
actorAvatarUrl: actor.avatarUrl,
createdAt: event.created_at
}
if (eventName === 'assigned' || eventName === 'unassigned') {
return {
...base,
assignee: event.assignee?.login ?? undefined
}
}
if (eventName === 'mentioned' || eventName === 'cross-referenced') {
return {
...base,
source: mapTimelineTarget(event.source?.issue)
}
}
if (eventName === 'closed') {
return {
...base,
stateReason: event.state_reason ?? null,
closer: mapTimelineTarget(event.closer ?? event.source?.issue)
}
}
if (eventName === 'moved_columns_in_project') {
return {
...base,
previousColumnName:
event.previous_column_name ?? event.project_card?.previous_column_name ?? null,
columnName: event.project_column_name ?? event.project_card?.column_name ?? null,
projectName: event.project?.name ?? null
}
}
return base
}
function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] {
const events: RestTimelineEvent[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
events.push(parsed)
}
} catch {
// Skip malformed jq lines; timeline activity is auxiliary to issue details.
}
}
return events
}
async function getIssueTimelineItems(
ownerRepo: GitHubOwnerRepoSlug,
issueNumber: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<GitHubIssueTimelineItem[]> {
try {
const items: GitHubIssueTimelineItem[] = []
for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) {
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`,
'--jq',
'.[] | @json'
],
ghOptions
)
// Why: --jq emits compact NDJSON while explicit pages let us stop once
// supported activity reaches the drawer cap.
const pageEvents = parseRestTimelineEventLines(stdout)
for (const event of pageEvents) {
const item = mapRestTimelineEvent(event)
if (!item) {
continue
}
items.push(item)
if (items.length === MAX_ISSUE_TIMELINE_ITEMS) {
break
}
}
if (pageEvents.length < GITHUB_REST_PAGE_SIZE) {
break
}
}
return items
} catch {
return []
}
}
async function getIssueDetailsViaGraphQL(
repoPath: string,
issueNumber: number,
@@ -136,6 +333,7 @@ async function getIssueDetailsViaGraphQL(
comments: PRComment[]
assignees: string[]
participants: GitHubAssignableUser[]
timelineItems: GitHubIssueTimelineItem[]
} | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
@@ -198,11 +396,13 @@ async function getIssueDetailsViaGraphQL(
name: u.name ?? null,
avatarUrl: u.avatarUrl ?? ''
}))
const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
return {
body: issue.body ?? '',
comments,
assignees,
participants
participants,
timelineItems
}
} catch {
return null
@@ -471,7 +671,12 @@ async function getIssueBodyAndComments(
issueNumber: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
): Promise<{
body: string
comments: PRComment[]
assignees: string[]
timelineItems: GitHubIssueTimelineItem[]
}> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
repoPath,
@@ -480,7 +685,7 @@ async function getIssueBodyAndComments(
)
try {
if (ownerRepo) {
const [issueResult, commentsResult] = await Promise.all([
const [issueResult, commentsResult, timelineItems] = await Promise.all([
ghExecFileAsync(
[
'api',
@@ -498,7 +703,8 @@ async function getIssueBodyAndComments(
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100`
],
ghOptions
)
),
getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
])
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
@@ -523,7 +729,7 @@ async function getIssueBodyAndComments(
})
)
const assignees = (issue.assignees ?? []).map((a) => a.login)
return { body: issue.body ?? '', comments, assignees }
return { body: issue.body ?? '', comments, assignees, timelineItems }
}
// Fallback: non-GitHub remote
const { stdout } = await ghExecFileAsync(
@@ -551,9 +757,9 @@ async function getIssueBodyAndComments(
})
)
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] }
} catch {
return { body: '', comments: [], assignees: [] }
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
}
@@ -802,12 +1008,13 @@ export async function getWorkItemDetails(
body: collapsed.body,
comments: collapsed.comments,
assignees: collapsed.assignees,
participants: collapsed.participants
participants: collapsed.participants,
timelineItems: collapsed.timelineItems
}
}
// Why: fall back to body/comments and GraphQL participants in parallel;
// the mention-participant merge is a cheap local operation afterward.
const [{ body, comments, assignees }, participants] = await Promise.all([
const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([
getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions),
getWorkItemParticipants(repoPath, item, connectionId, localGitOptions)
])
@@ -824,7 +1031,8 @@ export async function getWorkItemDetails(
body,
comments,
assignees,
participants: mentionParticipants
participants: mentionParticipants,
timelineItems
}
}
+6 -8
View File
@@ -18,6 +18,8 @@ vi.mock('os', async () => {
import { GrokHookService } from './hook-service'
const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
describe('GrokHookService', () => {
let homeDir: string
@@ -60,9 +62,7 @@ describe('GrokHookService', () => {
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*')
expect(config.hooks.Notification[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32'
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
: /grok-hook/
process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /grok-hook/
)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
@@ -74,7 +74,7 @@ describe('GrokHookService', () => {
)
expect(script).toContain('/hook/grok')
if (process.platform === 'win32') {
expect(script).toContain('powershell -NoProfile')
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
expect(script).toContain('payload=$(cat)')
}
@@ -99,9 +99,7 @@ describe('GrokHookService', () => {
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
)
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
@@ -137,7 +135,7 @@ describe('GrokHookService', () => {
expect(
commands.some((command) =>
process.platform === 'win32'
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
? WINDOWS_POWERSHELL_LAUNCHER.test(command)
: command.includes(GROK_SCRIPT_FILE_NAME)
)
).toBe(true)
+9 -1
View File
@@ -934,7 +934,7 @@ function recordProcessGoneCrash(
)
return
}
const key = getProcessGoneDedupeKey(processType, reason, exitCode)
const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
if (!processGoneDedupe.shouldRecord(key)) {
return
}
@@ -1440,6 +1440,8 @@ app.whenReady().then(async () => {
const terminalSnapshotLimit = 2_000
let terminalHandle: string
let terminalSessionId: string | null = null
let terminalPaneKey: string | null = null
let terminalPtyId: string | null = null
let workspaceId: string
let workspaceDisplayName: string | null = null
@@ -1453,6 +1455,8 @@ app.whenReady().then(async () => {
})
terminalHandle = created.startupTerminal?.handle ?? ''
terminalSessionId = created.startupTerminal?.tabId ?? null
terminalPaneKey = created.startupTerminal?.paneKey ?? null
terminalPtyId = created.startupTerminal?.ptyId ?? null
workspaceId = created.worktree.id
workspaceDisplayName = created.worktree.displayName ?? null
if (!terminalHandle) {
@@ -1475,6 +1479,8 @@ app.whenReady().then(async () => {
)
terminalHandle = terminal.handle
terminalSessionId = terminal.tabId ?? null
terminalPaneKey = terminal.paneKey ?? null
terminalPtyId = terminal.ptyId ?? null
workspaceId = terminal.worktreeId
const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`)
workspaceDisplayName = worktree.displayName ?? null
@@ -1509,6 +1515,8 @@ app.whenReady().then(async () => {
workspaceId,
workspaceDisplayName,
terminalSessionId,
terminalPaneKey,
terminalPtyId,
completion
}
}
+6 -1
View File
@@ -22,7 +22,11 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const key = String(args?.limit ?? 'default')
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
@@ -40,6 +44,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs()
}))()
+67
View File
@@ -299,6 +299,8 @@ describe('registerFilesystemHandlers', () => {
buffer.fill(0x61)
return { bytesRead: buffer.length, buffer }
}),
write: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn()
})
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
@@ -485,6 +487,71 @@ describe('registerFilesystemHandlers', () => {
expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything())
})
it('streams runtime download chunks to a temp sibling then promotes on finish', async () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile, close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
expect(started).toMatchObject({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const transferId = started.transferId
await expect(
handlers.get('fs:appendDownloadedFileChunk')!(null, {
transferId,
contentBase64: Buffer.from('hello').toString('base64')
})
).resolves.toEqual({ ok: true })
await expect(handlers.get('fs:finishDownloadedFile')!(null, { transferId })).resolves.toEqual({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
const tempPath = openMock.mock.calls[0][0]
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
expect(openMock).toHaveBeenCalledWith(tempPath, 'wx')
expect(writeFile).toHaveBeenCalledWith(Buffer.from('hello'))
expect(close).toHaveBeenCalled()
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
})
it('cleans up a runtime download temp file on cancel', async () => {
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile: vi.fn(), close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const tempPath = openMock.mock.calls[0][0]
await expect(
handlers.get('fs:cancelDownloadedFile')!(null, { transferId: started.transferId })
).resolves.toEqual({ ok: true })
expect(close).toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
expect(renameMock).not.toHaveBeenCalled()
})
it('cleans up the temp sibling when remote download transfer fails', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
+192
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises'
import type { FileHandle } from 'fs/promises'
import { randomUUID } from 'crypto'
import { dirname, extname, join, resolve } from 'path'
import type { ChildProcess } from 'child_process'
@@ -163,6 +164,24 @@ function sanitizeSaveDialogFilename(remoteBasename: string): string {
return sanitized
}
function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer {
if (encoding === 'base64') {
return Buffer.from(content, 'base64')
}
return Buffer.from(content, 'utf8')
}
type DownloadSession = {
destinationPath: string
tempPath: string
destinationExisted: boolean
handle: FileHandle
cleanupTimer: ReturnType<typeof setTimeout>
senderId: number
}
const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
}
@@ -430,6 +449,32 @@ export function registerFilesystemHandlers(
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers
): void {
const activeTextSearches = new Map<string, ChildProcess>()
const downloadSessions = new Map<string, DownloadSession>()
async function closeDownloadSession(
transferId: string,
cleanupTemp: boolean
): Promise<DownloadSession | null> {
const session = downloadSessions.get(transferId)
if (!session) {
return null
}
downloadSessions.delete(transferId)
clearTimeout(session.cleanupTimer)
await session.handle.close().catch(() => {})
if (cleanupTemp) {
await cleanupLocalTransferPath(session.tempPath)
}
return session
}
function cleanupDownloadSessionsForSender(senderId: number): void {
for (const [transferId, session] of Array.from(downloadSessions)) {
if (session.senderId === senderId) {
void closeDownloadSession(transferId, true)
}
}
}
// ─── Filesystem ─────────────────────────────────────────
ipcMain.handle(
@@ -552,6 +597,153 @@ export function registerFilesystemHandlers(
}
)
ipcMain.handle(
'fs:saveDownloadedFile',
async (
event,
args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' }
): Promise<DownloadFileResult> => {
const suggestedName = sanitizeSaveDialogFilename(
validateRequiredString(args?.suggestedName, 'suggestedName')
)
if (typeof args?.content !== 'string') {
throw new Error('content is required')
}
const content = args.content
const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8'
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogResult = parentWindow
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
: await dialog.showSaveDialog({ defaultPath: suggestedName })
if (dialogResult.canceled || !dialogResult.filePath) {
return { canceled: true }
}
const destinationPath = dialogResult.filePath
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
let promoted = false
try {
await writeFile(tempPath, decodeDownloadedFileContent(content, encoding))
await promoteDownloadedFile(tempPath, destinationPath, existed)
promoted = true
return { canceled: false, destinationPath }
} finally {
if (!promoted) {
await cleanupLocalTransferPath(tempPath)
}
}
}
)
ipcMain.handle(
'fs:startDownloadedFile',
async (
event,
args: { suggestedName?: string }
): Promise<
| { canceled: true }
| {
canceled: false
transferId: string
destinationPath: string
}
> => {
const suggestedName = sanitizeSaveDialogFilename(
validateRequiredString(args?.suggestedName, 'suggestedName')
)
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogResult = parentWindow
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
: await dialog.showSaveDialog({ defaultPath: suggestedName })
if (dialogResult.canceled || !dialogResult.filePath) {
return { canceled: true }
}
const destinationPath = dialogResult.filePath
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
const transferId = randomUUID()
try {
const handle = await open(tempPath, 'wx')
const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN
const cleanupTimer = setTimeout(() => {
void closeDownloadSession(transferId, true)
}, DOWNLOAD_SESSION_TTL_MS)
if (typeof cleanupTimer.unref === 'function') {
cleanupTimer.unref()
}
downloadSessions.set(transferId, {
destinationPath,
tempPath,
destinationExisted: existed,
handle,
cleanupTimer,
senderId
})
event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId))
return { canceled: false, transferId, destinationPath }
} catch (error) {
await cleanupLocalTransferPath(tempPath)
throw error
}
}
)
ipcMain.handle(
'fs:appendDownloadedFileChunk',
async (
_event,
args: { transferId?: string; contentBase64?: string }
): Promise<{ ok: true }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64')
const session = downloadSessions.get(transferId)
if (!session) {
throw new Error('Download session not found')
}
await session.handle.writeFile(Buffer.from(contentBase64, 'base64'))
return { ok: true }
}
)
ipcMain.handle(
'fs:finishDownloadedFile',
async (
_event,
args: { transferId?: string }
): Promise<{ canceled: false; destinationPath: string }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
const session = await closeDownloadSession(transferId, false)
if (!session) {
throw new Error('Download session not found')
}
let promoted = false
try {
await promoteDownloadedFile(
session.tempPath,
session.destinationPath,
session.destinationExisted
)
promoted = true
return { canceled: false, destinationPath: session.destinationPath }
} finally {
if (!promoted) {
await cleanupLocalTransferPath(session.tempPath)
}
}
}
)
ipcMain.handle(
'fs:cancelDownloadedFile',
async (_event, args: { transferId?: string }): Promise<{ ok: true }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
await closeDownloadSession(transferId, true)
return { ok: true }
}
)
ipcMain.handle(
'fs:listMarkdownDocuments',
async (
+27 -25
View File
@@ -384,38 +384,40 @@ describe('registerGitHubHandlers', () => {
expect(refreshPRNowMock).not.toHaveBeenCalled()
})
it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => {
it('skips stale visible PR refresh candidates without rejecting the batch', async () => {
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 7, once: vi.fn() } },
{
generation: 1,
candidates: [
{
cacheKey: 'valid::feature/test',
repoPath: '/workspace/repo',
repoId: 'repo-1',
branch: 'feature/test',
repoKind: 'git'
},
{
cacheKey: 'missing::feature/old',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/old',
repoKind: 'git'
}
]
}
)
expect(
handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 7, once: vi.fn() } },
{
generation: 1,
candidates: [
{
cacheKey: '/workspace/repo::feature/live',
repoPath: '/workspace/repo',
branch: 'feature/live',
repoKind: 'git',
repoId: 'repo-1'
},
{
cacheKey: '/workspace/missing::feature/stale',
repoPath: '/workspace/missing',
branch: 'feature/stale',
repoKind: 'git',
repoId: 'repo-missing'
}
]
}
)
).toBe(true)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith(
[
expect.objectContaining({
cacheKey: 'valid::feature/test',
repoPath: '/workspace/repo',
repoId: 'repo-1'
repoId: 'repo-1',
branch: 'feature/live'
})
],
1,
+3 -2
View File
@@ -331,7 +331,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
ipcMain.handle(
'gh:enqueuePRRefresh',
(
_event,
event,
args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
@@ -342,7 +342,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
if (validation.kind === 'skipped') {
return validation.result
}
enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0)
const senderWindowId = event?.sender?.id
enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId)
return { kind: 'queued' }
}
)
+428 -4
View File
@@ -38,6 +38,7 @@ const {
spawnMock,
openCodeBuildPtyEnvMock,
openCodeClearPtyMock,
mimoCodeBuildPtyEnvMock,
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
@@ -67,6 +68,7 @@ const {
getPathMock: vi.fn(),
spawnMock: vi.fn(),
openCodeBuildPtyEnvMock: vi.fn(),
mimoCodeBuildPtyEnvMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
openCodeClearPtyMock: vi.fn(),
buildAgentHookEnvMock: vi.fn(),
@@ -124,6 +126,12 @@ vi.mock('../opencode/hook-service', () => ({
}
}))
vi.mock('../mimo/hook-service', () => ({
mimoCodeHookService: {
buildPtyEnv: mimoCodeBuildPtyEnvMock
}
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
buildPtyEnv: buildAgentHookEnvMock,
@@ -271,6 +279,7 @@ describe('registerPtyHandlers', () => {
getPathMock.mockReset()
spawnMock.mockReset()
openCodeBuildPtyEnvMock.mockReset()
mimoCodeBuildPtyEnvMock.mockReset()
openCodeClearPtyMock.mockReset()
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
@@ -304,6 +313,9 @@ describe('registerPtyHandlers', () => {
? '/tmp/orca-opencode-overlay'
: '/tmp/orca-opencode-config'
}))
mimoCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingHome?: string) => ({
MIMOCODE_HOME: existingHome ? '/tmp/orca-mimocode-overlay' : '/tmp/orca-mimocode-shared'
}))
buildAgentHookEnvMock.mockReturnValue({
ORCA_AGENT_HOOK_PORT: '5678',
ORCA_AGENT_HOOK_TOKEN: 'agent-token'
@@ -758,6 +770,51 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('injects MiMo overlay env only when launch command is mimo', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'mimo')
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
})
it.each(['/usr/local/bin/mimo --prompt hi', '"C:\\Program Files\\MiMo\\mimo.cmd" --prompt hi'])(
'injects MiMo overlay env for path-qualified launch command %s',
async (launchCommand) => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, launchCommand)
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
}
)
it('does not inject MiMo overlay for non-mimo launches', async () => {
await spawnAndGetEnv()
expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled()
})
it('restores user MiMo home when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay',
ORCA_MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay',
ORCA_MIMOCODE_SOURCE_HOME: '/tmp/user-mimocode-home'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false }),
'mimo'
)
expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.MIMOCODE_HOME).toBe('/tmp/user-mimocode-home')
expect(env.ORCA_MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
})
posixOnlyIt(
'reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config',
async () => {
@@ -1903,6 +1960,9 @@ describe('registerPtyHandlers', () => {
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
expect(env.MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
@@ -3591,6 +3651,7 @@ describe('registerPtyHandlers', () => {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
@@ -3627,6 +3688,369 @@ describe('registerPtyHandlers', () => {
})
})
it('reuses runtime materialization when renderer focuses the same pane during spawn', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let resolveSpawn!: (result: { id: string }) => void
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '22222222-2222-4222-8222-222222222222'
const paneKey = makePaneKey('tab-race', leafId)
const runtimeSpawn = spawnController.spawn({
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
})
await Promise.resolve()
// Why: SSH can strip ORCA_PANE_KEY before spawn; tab/leaf metadata must
// still dedupe against runtime materialization.
const rendererSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
env: {
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'wt-1'
}
}) as Promise<{ id: string }>
await Promise.resolve()
expect(providerSpawn).toHaveBeenCalledTimes(1)
resolveSpawn({ id: 'pty-shared' })
await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([
{ id: 'pty-shared' },
{ id: 'pty-shared' }
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
ptyId: 'pty-shared'
})
})
it('reuses renderer spawn when runtime materialization starts for the same pane', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let resolveSpawn!: (result: { id: string }) => void
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const leafId = '33333333-3333-4333-8333-333333333333'
const paneKey = makePaneKey('tab-race', leafId)
const rendererSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'wt-1'
}
}) as Promise<{ id: string }>
await Promise.resolve()
const spawnController = controller as unknown as RuntimeSpawnController
const runtimeSpawn = spawnController.spawn({
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
})
await Promise.resolve()
expect(providerSpawn).toHaveBeenCalledTimes(1)
resolveSpawn({ id: 'pty-renderer' })
await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([
{ id: 'pty-renderer' },
{ id: 'pty-renderer' }
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
ptyId: 'pty-renderer'
})
})
it('settles the pane reservation when a post-spawn step throws so later spawns do not hang', async () => {
// Why: regression for the reservation leak — if a post-spawn helper throws
// after provider.spawn resolves (here registerPty), the reservation must be
// rejected and cleared. Otherwise every later spawn for the same pane key
// awaits a promise that never settles and the tab hangs forever.
registerPtyHandlers(mainWindow as never)
const leafId = '44444444-4444-4444-8444-444444444444'
const spawnArgs = { cols: 80, rows: 24, tabId: 'tab-reservation', leafId }
registerPtyMock.mockImplementationOnce(() => {
throw new Error('boom: post-spawn registration failed')
})
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow('boom')
// A second spawn for the same pane must run a fresh spawn rather than await
// the leaked (never-settled) reservation promise.
let hangTimer: ReturnType<typeof setTimeout> | undefined
const second = handlers.get('pty:spawn')!(null, spawnArgs) as Promise<{ id: string }>
const result = await Promise.race([
second,
new Promise<never>((_, reject) => {
hangTimer = setTimeout(
() => reject(new Error('second spawn hung: pane reservation leaked')),
1000
)
})
]).finally(() => clearTimeout(hangTimer))
expect(result.id).toEqual(expect.any(String))
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('settles the runtime-owned pane reservation when a post-spawn step throws so later spawns do not hang', async () => {
// Why: symmetry with the renderer-path regression — the runtime-controller
// spawn path keeps its own reservation, so it must also reject and clear it
// when a post-spawn helper (here runtime.registerPty) throws after
// provider.spawn resolves. Otherwise the next materialization for the same
// pane awaits a promise that never settles.
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let spawnCount = 0
const providerSpawn = vi.fn(async () => ({ id: `pty-${++spawnCount}` }))
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn().mockImplementationOnce(() => {
throw new Error('boom: runtime registration failed')
}),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '55555555-5555-4555-8555-555555555555'
const paneKey = makePaneKey('tab-runtime-reservation', leafId)
const spawnArgs = {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-runtime-reservation',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
}
await expect(spawnController.spawn(spawnArgs)).rejects.toThrow('boom')
// The reservation must be gone, so a second materialization runs a fresh
// provider.spawn instead of awaiting the leaked promise.
let hangTimer: ReturnType<typeof setTimeout> | undefined
const second = spawnController.spawn(spawnArgs)
const result = await Promise.race([
second,
new Promise<never>((_, reject) => {
hangTimer = setTimeout(
() => reject(new Error('second runtime spawn hung: pane reservation leaked')),
1000
)
})
]).finally(() => clearTimeout(hangTimer))
expect(result.id).toEqual(expect.any(String))
expect(providerSpawn).toHaveBeenCalledTimes(2)
})
it('records SSH leases for runtime-owned headless session bindings', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -4387,7 +4811,7 @@ describe('registerPtyHandlers', () => {
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD')
})
describe('Windows UTF-8 code page', () => {
@@ -6665,10 +7089,10 @@ describe('registerPtyHandlers', () => {
})
describe('main buffer snapshot dispatch', () => {
it('returns a sequenced main-owned terminal snapshot with clamped scrollback', async () => {
it('returns a hidden-output recovery snapshot with clamped scrollback', async () => {
const runtime = {
setPtyController: vi.fn(),
serializeMainTerminalBuffer: vi.fn().mockResolvedValue({
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
@@ -6685,7 +7109,7 @@ describe('registerPtyHandlers', () => {
opts: { scrollbackRows: 999_999 }
})
expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', {
expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 50_000
})
expect(result).toEqual({
+520 -362
View File
@@ -24,6 +24,11 @@ import {
resolveLocalWindowsTerminalRuntimeOptions
} from '../../shared/local-windows-terminal-runtime'
import { openCodeHookService } from '../opencode/hook-service'
import { mimoCodeHookService } from '../mimo/hook-service'
import {
getCommandTokenPathBasename,
getFirstCommandToken
} from '../../shared/command-token-scanner'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
@@ -162,6 +167,19 @@ export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListene
let pendingSerializerGenSeq = 0
const pendingByPaneKey = new Map<string, { gen: number; ownerWebContentsId: number | null }>()
const pendingPaneSerializerCleanupRegistered = new Set<number>()
type PaneSpawnReservation = {
promise: Promise<PaneSpawnReservationResult>
resolve: (result: PaneSpawnReservationResult) => void
reject: (error: unknown) => void
}
type PaneSpawnReservationResult = {
id: string
launchConfig?: SleepingAgentLaunchConfig
} & Partial<PtySpawnResult>
// Why: mobile runtime materialization and a newly-focused renderer pane can
// race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts
// the winner's PTY instead of creating a duplicate shell.
const paneSpawnReservationsByPaneKey = new Map<string, PaneSpawnReservation>()
// Why: at PTY spawn time we capture the gen that was pending for the spawn's
// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey
// remount that replaces the pending entry with a new gen would still get
@@ -227,6 +245,54 @@ function declarePendingPaneSerializer(paneKey: string, sender: WebContents | und
return gen
}
function reservePaneSpawn(paneKey: string): PaneSpawnReservation {
let resolve!: (result: PaneSpawnReservationResult) => void
let reject!: (error: unknown) => void
const promise = new Promise<PaneSpawnReservationResult>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
promise.catch(() => {})
const reservation = { promise, resolve, reject }
paneSpawnReservationsByPaneKey.set(paneKey, reservation)
return reservation
}
function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void {
if (paneSpawnReservationsByPaneKey.get(paneKey) === reservation) {
paneSpawnReservationsByPaneKey.delete(paneKey)
}
}
function rejectPaneSpawnReservation(
paneKey: string | null | undefined,
reservation: PaneSpawnReservation | null | undefined,
error: unknown
): void {
if (!reservation) {
return
}
reservation.reject(error)
if (paneKey) {
clearPaneSpawnReservation(paneKey, reservation)
}
}
function resolvePaneSpawnReservation<T extends PaneSpawnReservationResult>(
paneKey: string | null | undefined,
reservation: PaneSpawnReservation | null | undefined,
response: T
): T {
if (!reservation) {
return response
}
reservation.resolve(response)
if (paneKey) {
clearPaneSpawnReservation(paneKey, reservation)
}
return response
}
function settlePendingPaneSerializer(paneKey: string, gen: number): void {
if (pendingByPaneKey.get(paneKey)?.gen === gen) {
pendingByPaneKey.delete(paneKey)
@@ -611,6 +677,26 @@ function restoreOrStripOverlayEnv(
delete baseEnv[keys.source]
}
function isMimoLaunchCommand(launchCommand: string | undefined): boolean {
const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? ''))
.toLowerCase()
.replace(/\.(?:cmd|exe|sh)$/, '')
return binary === 'mimo'
}
function resolveMimocodeSourceHome(baseEnv: Record<string, string>): string | undefined {
const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME
if (sourceHome) {
return sourceHome
}
const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME
const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME
if (configHome && orcaHome && configHome === orcaHome) {
return undefined
}
return configHome
}
function resolveOpenCodeSourceConfigDir(baseEnv: Record<string, string>): string | undefined {
const sourceDir =
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
@@ -697,12 +783,29 @@ export function buildPtyHostEnv(
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
if (isMimoLaunchCommand(opts.launchCommand)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome))
if (baseEnv.MIMOCODE_HOME) {
baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME
if (preexistingMimocodeHome) {
baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome
} else {
delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME
}
}
}
} else {
restoreOrStripOverlayEnv(baseEnv, {
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
})
restoreOrStripOverlayEnv(baseEnv, {
primary: 'MIMOCODE_HOME',
overlay: 'ORCA_MIMOCODE_HOME',
source: 'ORCA_MIMOCODE_SOURCE_HOME'
})
}
// Why: Claude/Codex native hooks run inside the shell process, so Orca
@@ -1796,128 +1899,152 @@ export function registerPtyHandlers(
: undefined
}
const materializedPaneKey = hostSessionBinding
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
: null
const existingPaneSpawn = materializedPaneKey
? paneSpawnReservationsByPaneKey.get(materializedPaneKey)
: undefined
if (existingPaneSpawn) {
return await existingPaneSpawn.promise
}
const paneSpawnReservation = materializedPaneKey
? reservePaneSpawn(materializedPaneKey)
: null
let result: PtySpawnResult
try {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
try {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
result = await provider.spawn(spawnOptions)
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
ptySizes.delete(effectiveSessionAppId)
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
) {
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
if (isMintedSessionId && sessionId !== undefined) {
clearProviderPtyState(sessionId)
}
throw spawnError
} finally {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
result = await provider.spawn(spawnOptions)
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
ptyOwnership.set(result.id, args.connectionId ?? null)
const relayResultId = getRelayPtyId(args.connectionId, result.id)
const persistSshLease = (): void => {
if (!store || !args.connectionId) {
return
}
// Why: workspace-session bindings keep app-facing PTY ids for hydration,
// while SSH leases keep relay ids for remote lease reconciliation.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId)
? { leafId: args.leafId }
: {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (!hostSessionBinding) {
persistSshLease()
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) {
ptySizes.delete(effectiveSessionAppId)
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
) {
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
if (isMintedSessionId && sessionId !== undefined) {
clearProviderPtyState(sessionId)
}
throw spawnError
} finally {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
const relayResultId = getRelayPtyId(args.connectionId, result.id)
const persistSshLease = (): void => {
if (!store || !args.connectionId) {
return
}
// Why: workspace-session bindings keep app-facing PTY ids for hydration,
// while SSH leases keep relay ids for remote lease reconciliation.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId)
? { leafId: args.leafId }
: {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (!hostSessionBinding) {
persistSshLease()
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) {
ptySizes.delete(effectiveSessionAppId)
}
if (hostSessionBinding) {
try {
hostSessionBinding.store.persistPtyBinding({
worktreeId: hostSessionBinding.worktreeId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id
})
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
deletePtyOwnership(result.id)
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
if (hostSessionBinding) {
try {
hostSessionBinding.store.persistPtyBinding({
worktreeId: hostSessionBinding.worktreeId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id
})
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
deletePtyOwnership(result.id)
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(result.id)
}
clearProviderPtyState(result.id)
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
persistSshLease()
}
persistSshLease()
}
if (args.preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle)
}
if (args.worktreeId) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
if (args.preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle)
}
if (args.worktreeId) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
}
// Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler,
// so record their spawn-time paneKey here too. Synthetic hook titles and
// paneKey-scoped cache cleanup both depend on this reverse lookup.
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
if (!args.connectionId) {
registerPty({
ptyId: result.id,
worktreeId: args.worktreeId ?? null,
sessionId: sessionId ?? null,
paneKey,
pid:
typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0
? result.pid
: null
})
}
const response = { id: result.id }
return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: once the reservation is created, any later throw — spawn
// failure, persist failure, or a post-spawn helper such as
// registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err)
throw err
}
// Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler,
// so record their spawn-time paneKey here too. Synthetic hook titles and
// paneKey-scoped cache cleanup both depend on this reverse lookup.
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
if (!args.connectionId) {
registerPty({
ptyId: result.id,
worktreeId: args.worktreeId ?? null,
sessionId: sessionId ?? null,
paneKey,
pid:
typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0
? result.pid
: null
})
}
return { id: result.id }
},
write: (ptyId, data) => {
const provider = getProviderForPty(ptyId)
@@ -2103,7 +2230,7 @@ export function registerPtyHandlers(
}
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
try {
return await runtime.serializeMainTerminalBuffer(args.id, { scrollbackRows })
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
} catch {
return null
}
@@ -2252,6 +2379,13 @@ export function registerPtyHandlers(
verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null
const metadataLeafId =
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
const metadataPaneKey =
typeof args.tabId === 'string' &&
args.tabId.length > 0 &&
args.tabId.length <= 512 &&
metadataLeafId
? makePaneKey(args.tabId, metadataLeafId)
: null
const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey)
const migrationUnsupportedPaneKey =
legacySpawnPaneKey &&
@@ -2323,6 +2457,9 @@ export function registerPtyHandlers(
delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN
}
const validatedPaneKey = stablePaneKey
// Why: SSH can strip ORCA_PANE_KEY when remote hooks are disabled; the
// IPC tab/leaf metadata still names the pane and matches runtime fallback.
const reservationPaneKey = metadataPaneKey ?? validatedPaneKey
const validatedLeafId = verifiedLeafId ?? metadataLeafId
let env: Record<string, string> | undefined = baseEnv
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
@@ -2458,271 +2595,292 @@ export function registerPtyHandlers(
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
const existingPaneSpawn = reservationPaneKey
? paneSpawnReservationsByPaneKey.get(reservationPaneKey)
: undefined
if (existingPaneSpawn) {
return await existingPaneSpawn.promise
}
const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null
let result: PtySpawnResult
try {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.add(preAllocatedHandle)
try {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.add(preAllocatedHandle)
}
result = await provider.spawn(spawnOptions)
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
ptySizes.delete(effectiveSessionAppId)
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
) {
// Why: expired remote reattach means the relay has already dropped
// the backing PTY. Clear the durable lease so later session writes
// cannot restore the stale pane binding.
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
// Why: if buildPtyHostEnv materialized provider state for this minted
// id but provider.spawn failed, that state would otherwise leak.
if (isMintedSessionId && effectiveSessionId !== undefined) {
clearProviderPtyState(effectiveSessionId)
}
// Why: telemetry-plan.md§agent_error — when the renderer threaded
// agent_kind through args.telemetry, attribute the error to that agent.
// Otherwise fall back to sniffing the command for `claude` (the one
// agent the main process can identify on its own via the existing
// `isClaudeLaunchCommand` regex used for auth gating). Bare-shell
// catches and unknown-agent catches without renderer telemetry remain
// unattributed. The event still emits with a classified `error_class`;
// raw error messages are dropped at the telemetry validator boundary.
const rendererAgentKindParse =
args.telemetry?.agent_kind !== undefined
? agentKindSchema.safeParse(args.telemetry.agent_kind)
: null
const errorAgentKind = rendererAgentKindParse?.success
? rendererAgentKindParse.data
: isClaudeLaunch
? ('claude-code' as const)
: null
if (errorAgentKind) {
const classified = classifyError(spawnError)
track('agent_error', {
agent_kind: errorAgentKind,
error_class: classified.error_class,
...getCohortAtEmit()
})
}
throw spawnError
} finally {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
result = await provider.spawn(spawnOptions)
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
ptySizes.delete(effectiveSessionAppId)
ptyOwnership.set(result.id, args.connectionId ?? null)
const relayResultId = getRelayPtyId(args.connectionId, result.id)
if (store && args.connectionId) {
// Why: remote PTYs live in the SSH relay grace window after Orca
// detaches. Persist their IDs immediately so reconnect can reattach
// instead of treating the tab as a fresh shell.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(validatedLeafId ? { leafId: validatedLeafId } : {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
// Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217)
// for local daemon PTYs and the equivalent remote-relay race for SSH.
// The renderer's debounced session writer runs in parallel for every
// other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId)
// binding synchronously so a force-quit in the ~450 ms debounce window
// cannot orphan either daemon history or a remote relay PTY lease.
if (
(isDaemonHostSpawn || args.connectionId) &&
store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
validatedLeafId !== null
) {
try {
store.persistPtyBinding({
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id
})
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(result.id)
deletePtyOwnership(result.id)
}
if (!result.isReattach && args.connectionId && store) {
store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
}
// Why: pre-signal cooperation gate — when the renderer has declared it
// will own the serializer for this paneKey, suppress the daemon-snapshot
// seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer)
// is the sole authority. The pre-signal is keyed on paneKey because at
// spawn time the renderer doesn't yet know the new ptyId. See
// docs/mobile-prefer-renderer-scrollback.md.
const rendererPreSignaled = validatedPaneKey
? pendingByPaneKey.has(validatedPaneKey)
: false
const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id)
// Why: capture the pending gen at spawn time so teardown for THIS PTY
// only settles its own generation. A remount that replaces the entry
// with a new gen must not be stomped by the old PTY's teardown.
if (validatedPaneKey && rendererPreSignaled) {
const pending = pendingByPaneKey.get(validatedPaneKey)
if (pending) {
ptyPendingGenByPtyId.set(result.id, pending.gen)
}
}
// Why: hydrate the runtime's headless emulator with the adapter's
// restore data BEFORE registerPty so any live PTY data that arrives
// concurrently lands on top of the seed instead of replacing it. Mobile
// subscribers then see the same scrollback the desktop xterm received
// via coldRestore/snapshot. Without this, mobile snapshots after a
// daemon-restored attach contain only bytes emitted since the relaunch
// and the prior agent output silently disappears.
//
// Skip when the renderer is or will be authoritative for this PTY:
// its hydration path will seed the emulator from xterm's live buffer,
// which is richer than the daemon snapshot.
if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
const seedSize =
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
result.coldRestore.scrollback.length > 0
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
})
}
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
) {
// Why: expired remote reattach means the relay has already dropped
// the backing PTY. Clear the durable lease so later session writes
// cannot restore the stale pane binding.
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
// Why: if buildPtyHostEnv materialized provider state for this minted
// id but provider.spawn failed, that state would otherwise leak.
if (isMintedSessionId && effectiveSessionId !== undefined) {
clearProviderPtyState(effectiveSessionId)
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
// Why: telemetry-plan.md§agent_error — when the renderer threaded
// agent_kind through args.telemetry, attribute the error to that agent.
// Otherwise fall back to sniffing the command for `claude` (the one
// agent the main process can identify on its own via the existing
// `isClaudeLaunchCommand` regex used for auth gating). Bare-shell
// catches and unknown-agent catches without renderer telemetry remain
// unattributed. The event still emits with a classified `error_class`;
// raw error messages are dropped at the telemetry validator boundary.
const rendererAgentKindParse =
args.telemetry?.agent_kind !== undefined
? agentKindSchema.safeParse(args.telemetry.agent_kind)
: null
const errorAgentKind = rendererAgentKindParse?.success
? rendererAgentKindParse.data
: isClaudeLaunch
? ('claude-code' as const)
: null
if (errorAgentKind) {
const classified = classifyError(spawnError)
track('agent_error', {
agent_kind: errorAgentKind,
error_class: classified.error_class,
...getCohortAtEmit()
})
}
throw spawnError
} finally {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
const relayResultId = getRelayPtyId(args.connectionId, result.id)
if (store && args.connectionId) {
// Why: remote PTYs live in the SSH relay grace window after Orca
// detaches. Persist their IDs immediately so reconnect can reattach
// instead of treating the tab as a fresh shell.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(validatedLeafId ? { leafId: validatedLeafId } : {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
// Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217)
// for local daemon PTYs and the equivalent remote-relay race for SSH.
// The renderer's debounced session writer runs in parallel for every
// other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId)
// binding synchronously so a force-quit in the ~450 ms debounce window
// cannot orphan either daemon history or a remote relay PTY lease.
if (
(isDaemonHostSpawn || args.connectionId) &&
store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
validatedLeafId !== null
) {
try {
store.persistPtyBinding({
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id
})
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
// Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned
// spawn (see pty-connection.ts). Recording the mapping here lets
// clearProviderPtyState clear the agent-hooks server's per-paneKey
// caches when the PTY exits.
// Why: args.env arrives as untrusted JSON over IPC — the static
// Record<string, string> type is not actually enforced at the boundary.
// Narrow to a bounded string so malformed or oversized values cannot
// pollute ptyPaneKey or the downstream clearPaneState call.
const rememberedPaneKey = validatedPaneKey
? rememberPaneKeyForPty(result.id, validatedPaneKey)
: null
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
legacySpawnPaneKey.paneKey,
migrationUnsupportedPaneKey,
result.id
)
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
} else if (validatedPaneKey) {
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(result.id)
deletePtyOwnership(result.id)
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
}
if (!result.isReattach && args.connectionId && store) {
store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
}
// Why: pre-signal cooperation gate — when the renderer has declared it
// will own the serializer for this paneKey, suppress the daemon-snapshot
// seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer)
// is the sole authority. The pre-signal is keyed on paneKey because at
// spawn time the renderer doesn't yet know the new ptyId. See
// docs/mobile-prefer-renderer-scrollback.md.
const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false
const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id)
// Why: capture the pending gen at spawn time so teardown for THIS PTY
// only settles its own generation. A remount that replaces the entry
// with a new gen must not be stomped by the old PTY's teardown.
if (validatedPaneKey && rendererPreSignaled) {
const pending = pendingByPaneKey.get(validatedPaneKey)
if (pending) {
ptyPendingGenByPtyId.set(result.id, pending.gen)
}
}
// Why: hydrate the runtime's headless emulator with the adapter's
// restore data BEFORE registerPty so any live PTY data that arrives
// concurrently lands on top of the seed instead of replacing it. Mobile
// subscribers then see the same scrollback the desktop xterm received
// via coldRestore/snapshot. Without this, mobile snapshots after a
// daemon-restored attach contain only bytes emitted since the relaunch
// and the prior agent output silently disappears.
//
// Skip when the renderer is or will be authoritative for this PTY:
// its hydration path will seed the emulator from xterm's live buffer,
// which is richer than the daemon snapshot.
if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
const seedSize =
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
result.coldRestore.scrollback.length > 0
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
// Why: register local PTYs (connectionId falsy) with the memory
// collector so it can walk each PTY's process subtree and attribute
// memory back to its worktree. SSH PTYs execute remotely and their
// process tree is not visible to our local `ps`, so we skip them.
if (!args.connectionId) {
// Why: providers publish the OS pid on the spawn result (both
// LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps
// the memory module from reaching back into ipc/pty on a hot path, and
// works uniformly whether the PTY is hosted in-process or by the
// daemon subprocess.
const spawnedPid = result.pid ?? null
// Why: args.worktreeId and args.sessionId arrive as untrusted IPC
// payload strings — the static type is not enforced at the boundary.
// Narrow them to bounded strings here to match the paneKey defense
// above so malformed or oversized values cannot pollute registerPty's
// maps or downstream memory-attribution lookups.
registerPty({
ptyId: result.id,
worktreeId:
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
? args.worktreeId
: null,
sessionId:
typeof args.sessionId === 'string' &&
args.sessionId.length > 0 &&
args.sessionId.length <= 256
? args.sessionId
: null,
paneKey: rememberedPaneKey,
pid:
typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0
? spawnedPid
: null
})
}
}
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
// Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned
// spawn (see pty-connection.ts). Recording the mapping here lets
// clearProviderPtyState clear the agent-hooks server's per-paneKey
// caches when the PTY exits.
// Why: args.env arrives as untrusted JSON over IPC — the static
// Record<string, string> type is not actually enforced at the boundary.
// Narrow to a bounded string so malformed or oversized values cannot
// pollute ptyPaneKey or the downstream clearPaneState call.
const rememberedPaneKey = validatedPaneKey
? rememberPaneKeyForPty(result.id, validatedPaneKey)
: null
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
legacySpawnPaneKey.paneKey,
migrationUnsupportedPaneKey,
result.id
)
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
} else if (validatedPaneKey) {
if (!result.isReattach) {
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
// Why: telemetry-plan.md§Agent launch semantics — fire `agent_started`
// only after `provider.spawn` resolved. The renderer threads
// `args.telemetry` through the spawn IPC for every launch we want to
// attribute; bare-shell tabs (no agent) leave the field undefined and
// do not produce an event. Each field is parsed against its closed
// enum here so a malformed renderer payload (or a spoofed IPC) does
// not poison the event — `safeParse` failure drops that field, and
// if any required field is missing we skip the event entirely. The
// main-side `track()` validator re-runs the schema on the full
// payload as a second defense-in-depth check.
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
}
}
// Why: register local PTYs (connectionId falsy) with the memory
// collector so it can walk each PTY's process subtree and attribute
// memory back to its worktree. SSH PTYs execute remotely and their
// process tree is not visible to our local `ps`, so we skip them.
if (!args.connectionId) {
// Why: providers publish the OS pid on the spawn result (both
// LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps
// the memory module from reaching back into ipc/pty on a hot path, and
// works uniformly whether the PTY is hosted in-process or by the
// daemon subprocess.
const spawnedPid = result.pid ?? null
// Why: args.worktreeId and args.sessionId arrive as untrusted IPC
// payload strings — the static type is not enforced at the boundary.
// Narrow them to bounded strings here to match the paneKey defense
// above so malformed or oversized values cannot pollute registerPty's
// maps or downstream memory-attribution lookups.
registerPty({
ptyId: result.id,
worktreeId:
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
? args.worktreeId
: null,
sessionId:
typeof args.sessionId === 'string' &&
args.sessionId.length > 0 &&
args.sessionId.length <= 256
? args.sessionId
: null,
paneKey: rememberedPaneKey,
pid:
typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0
? spawnedPid
: null
})
}
// Why: telemetry-plan.md§Agent launch semantics — fire `agent_started`
// only after `provider.spawn` resolved. The renderer threads
// `args.telemetry` through the spawn IPC for every launch we want to
// attribute; bare-shell tabs (no agent) leave the field undefined and
// do not produce an event. Each field is parsed against its closed
// enum here so a malformed renderer payload (or a spoofed IPC) does
// not poison the event — `safeParse` failure drops that field, and
// if any required field is missing we skip the event entirely. The
// main-side `track()` validator re-runs the schema on the full
// payload as a second defense-in-depth check.
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
const response = {
...result,
...(!result.isReattach && effectiveLaunchConfig
? { launchConfig: effectiveLaunchConfig }
: {})
}
}
return {
...result,
...(!result.isReattach && effectiveLaunchConfig
? { launchConfig: effectiveLaunchConfig }
: {})
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: once the reservation is created, any later throw —
// spawn failure, persist failure, or a post-spawn helper such as
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err)
throw err
}
}
)
+142
View File
@@ -20,6 +20,7 @@ const {
mockFilesystemProvider,
mockMultiplexer,
gitSpawnMock,
listWorktreeGraphMock,
invalidateAuthorizedRootsCacheMock,
prepareLocalWorktreeRootForRepoMock
} = vi.hoisted(() => ({
@@ -45,6 +46,7 @@ const {
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
listWorktrees: vi.fn().mockResolvedValue([]),
getHostPlatform: vi.fn().mockReturnValue({
relayPlatform: 'linux-x64',
os: 'linux',
@@ -68,6 +70,7 @@ const {
notify: vi.fn()
},
gitSpawnMock: vi.fn(),
listWorktreeGraphMock: vi.fn(),
invalidateAuthorizedRootsCacheMock: vi.fn(),
prepareLocalWorktreeRootForRepoMock: vi.fn()
}))
@@ -103,6 +106,10 @@ vi.mock('../git/runner', () => ({
gitSpawn: gitSpawnMock
}))
vi.mock('../git/worktree', () => ({
listWorktreeGraph: listWorktreeGraphMock
}))
vi.mock('./filesystem-auth', () => ({
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock
}))
@@ -158,6 +165,7 @@ describe('projectGroups IPC validation', () => {
mockStore.updateProjectGroup.mockReset()
mockStore.deleteProjectGroup.mockReset()
mockStore.moveProjectToGroup.mockReset()
mockStore.addRepo.mockReset()
mockStore.getProjects.mockReset().mockReturnValue([])
mockStore.getProjectHostSetups.mockReset().mockReturnValue([])
mockStore.updateProjectHostSetup.mockReset()
@@ -171,6 +179,10 @@ describe('projectGroups IPC validation', () => {
mockFilesystemProvider.stat.mockRejectedValue(new Error('not found'))
mockGitProvider.isGitRepoAsync.mockReset()
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mockGitProvider.listWorktrees.mockReset()
mockGitProvider.listWorktrees.mockResolvedValue([])
listWorktreeGraphMock.mockReset()
listWorktreeGraphMock.mockResolvedValue([])
vi.mocked(isGitRepo).mockReset()
vi.mocked(isGitRepo).mockReturnValue(true)
mockMultiplexer.notify.mockReset()
@@ -643,6 +655,75 @@ describe('projectGroups IPC validation', () => {
})
})
it('resolves SSH linked worktree imports through the SSH provider worktree graph', async () => {
const selectedPath = '/srv/platform/demo/brash-binder'
const secondSelectedPath = '/srv/platform/demo/quick-howler'
const mainPath = '/srv/source/demo-project'
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === selectedPath || path === secondSelectedPath,
rootPath: '/srv/provider/root'
}))
mockGitProvider.listWorktrees.mockResolvedValue([
{
path: mainPath,
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: selectedPath,
head: 'feature-head',
branch: 'refs/heads/brash-binder',
isBare: false,
isMainWorktree: false
},
{
path: secondSelectedPath,
head: 'feature-head',
branch: 'refs/heads/quick-howler',
isBare: false,
isMainWorktree: false
}
])
mockFilesystemProvider.stat.mockImplementation(async (path: string) => {
if (path === `${selectedPath}/.git` || path === `${secondSelectedPath}/.git`) {
return { type: 'directory', size: 0, mtime: 0 }
}
throw new Error('not found')
})
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform/demo'
? [
{ name: 'brash-binder', isDirectory: true, isSymlink: false },
{ name: 'quick-howler', isDirectory: true, isSymlink: false }
]
: []
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform/demo',
groupName: '',
projectPaths: [selectedPath, secondSelectedPath],
connectionId: 'conn-1',
mode: 'separate'
})
expect(result).toMatchObject({ importedCount: 1, alreadyKnownCount: 1, failedCount: 0 })
expect(mockGitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath)
expect(mockGitProvider.listWorktrees).toHaveBeenCalledTimes(1)
expect(listWorktreeGraphMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: mainPath,
connectionId: 'conn-1'
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: mainPath
})
})
it('imports a small selection from a large nested SSH scan', async () => {
const group = {
id: 'group-1',
@@ -709,6 +790,67 @@ describe('projectGroups IPC validation', () => {
)
})
it('imports selected local linked worktrees as one project rooted at the main worktree', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-nested-linked-worktrees-'))
try {
const parentPath = join(tempRoot, 'paseo-worktrees', 'demo-project')
const mainPath = join(tempRoot, 'source', 'demo-project')
const firstWorktreePath = join(parentPath, 'brash-binder')
const secondWorktreePath = join(parentPath, 'quick-howler')
await mkdir(join(firstWorktreePath, '.git'), { recursive: true })
await mkdir(join(secondWorktreePath, '.git'), { recursive: true })
await mkdir(mainPath, { recursive: true })
vi.mocked(isGitRepo).mockReturnValue(false)
vi.mocked(isGitRepo).mockImplementation((path: string) =>
[firstWorktreePath, secondWorktreePath, mainPath].includes(path)
)
listWorktreeGraphMock.mockResolvedValue([
{
path: mainPath,
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: firstWorktreePath,
head: 'feature-head',
branch: 'refs/heads/brash-binder',
isBare: false,
isMainWorktree: false
},
{
path: secondWorktreePath,
head: 'feature-head',
branch: 'refs/heads/quick-howler',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath,
groupName: '',
projectPaths: [firstWorktreePath, secondWorktreePath],
mode: 'separate'
})
expect(result).toMatchObject({
importedCount: 1,
alreadyKnownCount: 1,
failedCount: 0
})
expect(mockStore.addRepo).toHaveBeenCalledTimes(1)
expect(mockStore.addRepo).toHaveBeenCalledWith(expect.objectContaining({ path: mainPath }))
expect(listWorktreeGraphMock).toHaveBeenCalledTimes(1)
expect((result as { projects: { projectId?: string }[] }).projects[0].projectId).toBe(
(result as { projects: { projectId?: string }[] }).projects[1].projectId
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('sanitizes unexpected nested import errors before returning results', async () => {
const group = {
id: 'group-1',
+48 -9
View File
@@ -57,6 +57,7 @@ import {
createNestedProjectGroupResolver,
resolveNestedRepoSelection
} from '../project-groups/nested-repo-import'
import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target'
import {
isGitRepo,
getGitUsername,
@@ -81,6 +82,7 @@ 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 { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
@@ -792,6 +794,11 @@ const FolderWorkspacePathStatusArgs = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('project-group'),
projectGroupId: z.string().min(1)
}),
z.object({
scope: z.literal('path'),
path: z.string().min(1),
connectionId: z.string().min(1).nullable().optional()
})
])
@@ -1137,10 +1144,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('sparsePresets:remove')
ipcMain.handle('repos:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getRepos()
})
ipcMain.handle('projects:list', () => store.getProjects())
ipcMain.handle('projects:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjects()
})
ipcMain.handle('projects:update', (_event, rawArgs: ProjectUpdateArgs): Project | null => {
const args = parseProjectGroupIpcArgs(
@@ -1151,7 +1166,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.updateProject(args.projectId, args.updates)
})
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle('projectHostSetups:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjectHostSetups()
})
ipcMain.handle(
'projectHostSetups:create',
@@ -1479,12 +1499,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
error: 'Repository was not found in the nested repo scan result'
})
)
const importedProjectIdsByRepoPath = new Map<string, string>()
const importTargetResolver = createNestedRepoImportTargetResolver()
for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) {
try {
let importRepoPath = repoPath
if (args.connectionId) {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider || !(await gitProvider.isGitRepoAsync(repoPath)).isRepo) {
const check = gitProvider ? await gitProvider.isGitRepoAsync(repoPath) : null
if (!gitProvider || !check?.isRepo) {
results.push({
path: repoPath,
status: 'failed',
@@ -1492,35 +1516,49 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
})
continue
}
importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider)
} else if (!isGitRepo(repoPath)) {
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
continue
} else {
importRepoPath = await importTargetResolver.resolveLocal(repoPath)
}
const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath)
const alreadyImportedProjectId =
importedProjectIdsByRepoPath.get(normalizedImportRepoPath)
if (alreadyImportedProjectId) {
results.push({
path: repoPath,
projectId: alreadyImportedProjectId,
status: 'already-known'
})
continue
}
const existing = store
.getRepos()
.find(
(repo) =>
(repo.connectionId ?? null) === (args.connectionId ?? null) &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(repoPath)
normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath
)
const group = groupResolver.getGroupForRepo(repoPath)
if (existing) {
if (group) {
store.moveProjectToGroup(existing.id, group.id, projectGroupOrder)
}
importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id)
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
continue
}
const detected = await detectRepoIconAndUpstream({
repoPath,
repoPath: importRepoPath,
kind: 'git',
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: repoPath,
displayName: getRepoName(repoPath),
path: importRepoPath,
displayName: getRepoName(importRepoPath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
@@ -1540,9 +1578,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
await prepareLocalWorktreeRootForRepo(store, repo)
if (args.connectionId) {
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: repoPath
rootPath: importRepoPath
})
}
importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id)
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
// Why: nested-repo import only reaches here after the isGitRepo /
// isGitRepoAsync guard above confirmed a git repo, so always `true`.
+27
View File
@@ -18,6 +18,7 @@ import {
mergeWorktree,
parseWorktreeId,
formatWorktreeRemovalError,
isWindowsLongPathWorktreeRemovalError,
isOrphanCompatiblePreflightError,
isOrphanedWorktreeError,
areWorktreePathsEqual
@@ -535,6 +536,32 @@ describe('isOrphanedWorktreeError', () => {
})
})
describe('isWindowsLongPathWorktreeRemovalError', () => {
it('matches Git for Windows long-path deletion failures on Windows', () => {
const error = Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete some/deep/file: Filename too long'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(true)
})
it('does not match long-path text off Windows', () => {
const error = Object.assign(new Error('file name too long'), {
stderr: 'Filename too long'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'linux')).toBe(false)
})
it('does not match unrelated Git removal failures on Windows', () => {
const error = Object.assign(new Error('git worktree remove failed'), {
stderr: 'fatal: contains modified or untracked files'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(false)
})
})
describe('isOrphanCompatiblePreflightError', () => {
it('matches not-a-working-tree errors', () => {
const error = Object.assign(new Error('git failed'), {
+17
View File
@@ -281,6 +281,23 @@ export function isOrphanedWorktreeError(error: unknown): boolean {
return /is not a working tree/.test(msg)
}
export function isWindowsLongPathWorktreeRemovalError(
error: unknown,
platform: NodeJS.Platform = process.platform
): boolean {
if (platform !== 'win32' || typeof error !== 'object' || error === null) {
return false
}
const errorWithDetails = error as { message?: unknown; stderr?: unknown; stdout?: unknown }
const details = [errorWithDetails.stderr, errorWithDetails.stdout, errorWithDetails.message]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
// Why: Git for Windows has reported this failure through both stderr and the
// thrown message, with wording that varies between "filename" and "path".
return /(?:file ?name|path).{0,40}too long|too long.{0,40}(?:file ?name|path)/i.test(details)
}
export function isOrphanCompatiblePreflightError(error: unknown): boolean {
if (isOrphanedWorktreeError(error)) {
return true
+182
View File
@@ -2037,6 +2037,55 @@ describe('registerWorktreeHandlers', () => {
})
})
it('does not reuse host detected worktree scans for a selected WSL runtime', async () => {
listWorktreesMock
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'host-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'wsl-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
const hostResult = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
setPlatform('win32')
store.getProjects.mockReturnValue([
{
id: 'project-1',
displayName: 'repo',
badgeColor: '#000',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
createdAt: 0,
updatedAt: 0
}
])
const wslResult = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(hostResult.worktrees[0].head).toBe('host-head')
expect(wslResult.worktrees[0].head).toBe('wsl-head')
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
expect(listWorktreesMock).toHaveBeenNthCalledWith(1, '/workspace/repo')
expect(listWorktreesMock).toHaveBeenNthCalledWith(2, '/workspace/repo', {
wslDistro: 'Ubuntu'
})
})
it('reuses a recent authoritative detected worktree scan', async () => {
listWorktreesMock.mockResolvedValue([
{
@@ -5391,6 +5440,139 @@ describe('registerWorktreeHandlers', () => {
})
})
it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => {
setPlatform('win32')
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-'))
const repoPath = join(parentDir, 'repo')
const worktreePath = join(parentDir, 'feature-wt')
await mkdir(worktreePath, { recursive: true })
await writeFile(join(worktreePath, 'scratch.txt'), 'delete me')
mockKnownFeatureWorktree(worktreePath, repoPath)
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
const longPathError = Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete deep/file.txt: Filename too long'
})
removeWorktreeMock.mockRejectedValue(longPathError)
const worktreeId = `repo-1::${worktreePath}`
try {
const result = await handlers['worktrees:remove'](null, {
worktreeId,
force: true
})
expect(result).toEqual({
preservedBranch: { branchName: 'feature', head: 'feature' }
})
if (ORIGINAL_PLATFORM === 'win32') {
await expect(lstat(worktreePath)).rejects.toMatchObject({ code: 'ENOENT' })
}
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
cwd: '/workspace/repo'
})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
} finally {
await rm(parentDir, { recursive: true, force: true })
}
})
it('does not create a preserved-branch target when long-path recovery preserves branch by policy', async () => {
setPlatform('win32')
mockKnownFeatureWorktree()
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
removeWorktreeMock.mockRejectedValue(
Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete deep/file.txt: Filename too long'
})
)
const result = await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
force: true
})
expect(result).toEqual({})
await expect(
handlers['worktrees:forceDeletePreservedBranch'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
branchName: 'feature',
expectedHead: 'feature'
})
).rejects.toThrow('No preserved branch cleanup is pending')
})
it('does not recover Windows long-path worktree removal without force', async () => {
setPlatform('win32')
mockKnownFeatureWorktree()
const longPathError = Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete deep/file.txt: Filename too long'
})
removeWorktreeMock.mockRejectedValue(longPathError)
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt.')
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('keeps metadata when Windows long-path recovery deletes the directory but prune fails', async () => {
setPlatform('win32')
mockKnownFeatureWorktree()
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
removeWorktreeMock.mockRejectedValue(
Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete deep/file.txt: Filename too long'
})
)
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('git prune failed'), {
stderr: 'fatal: unable to lock worktree admin dir'
})
)
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
force: true
})
).rejects.toThrow('Git still has stale worktree registration')
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('retries stale Git registration cleanup after prior local filesystem recovery', async () => {
setPlatform('win32')
const missingWorktreePath = 'C:\\workspace\\already-removed'
const worktreeId = `repo-1::${missingWorktreePath}`
mockKnownFeatureWorktree(missingWorktreePath)
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
const result = await handlers['worktrees:remove'](null, {
worktreeId,
force: true
})
expect(result).toEqual({
preservedBranch: { branchName: 'feature', head: 'feature' }
})
expect(runHookMock).not.toHaveBeenCalled()
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
cwd: '/workspace/repo'
})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
})
it('refuses to delete the root workspace for folder-mode repos', async () => {
store.getRepo.mockReturnValue({
id: 'repo-folder',
+110 -30
View File
@@ -125,6 +125,10 @@ import {
removeLocalWorktreePath,
toLocalWorktreeRuntimePath
} from '../local-worktree-filesystem'
import {
pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
recoverLocalWindowsLongPathWorktreeRemoval
} from '../local-worktree-removal-recovery'
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
const WORKTREE_LIST_ALL_CONCURRENCY = 8
@@ -407,12 +411,19 @@ const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>
const detectedWorktreeScanGenerations = new Map<string, number>()
function invalidateDetectedWorktreeScanCache(repoId: string): void {
detectedWorktreeScanCache.delete(repoId)
detectedWorktreeScanInFlight.delete(repoId)
detectedWorktreeScanGenerations.set(
repoId,
(detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1
)
const keyPrefix = `${repoId}\0`
for (const key of new Set([
...detectedWorktreeScanCache.keys(),
...detectedWorktreeScanInFlight.keys(),
...detectedWorktreeScanGenerations.keys()
])) {
if (!key.startsWith(keyPrefix)) {
continue
}
detectedWorktreeScanCache.delete(key)
detectedWorktreeScanInFlight.delete(key)
detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1)
}
}
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
@@ -427,45 +438,54 @@ async function listDetectedGitWorktrees(
store: Store,
repo: Repo
): Promise<DetectedWorktreeScanResult> {
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
if (repo.connectionId || isFolderRepo(repo)) {
return {
gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)),
gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions),
fresh: true
}
}
const cached = detectedWorktreeScanCache.get(repo.id)
const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions)
const cached = detectedWorktreeScanCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return { gitWorktrees: cached.worktrees, fresh: false }
}
const inFlight = detectedWorktreeScanInFlight.get(repo.id)
const inFlight = detectedWorktreeScanInFlight.get(cacheKey)
if (inFlight) {
return { gitWorktrees: await inFlight, fresh: false }
}
const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))
const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0
detectedWorktreeScanInFlight.set(repo.id, scan)
const scan = listRepoWorktrees(repo, localWorktreeGitOptions)
const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0
detectedWorktreeScanInFlight.set(cacheKey, scan)
try {
const gitWorktrees = await scan
// Why: a create/remove notification can invalidate while the git scan is
// still running. Do not let that stale scan repopulate the cache afterward.
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation
if (isCurrentGeneration) {
detectedWorktreeScanCache.set(repo.id, {
detectedWorktreeScanCache.set(cacheKey, {
worktrees: gitWorktrees,
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
})
}
return { gitWorktrees, fresh: isCurrentGeneration }
} finally {
if (detectedWorktreeScanInFlight.get(repo.id) === scan) {
detectedWorktreeScanInFlight.delete(repo.id)
if (detectedWorktreeScanInFlight.get(cacheKey) === scan) {
detectedWorktreeScanInFlight.delete(cacheKey)
}
}
}
function getDetectedWorktreeScanCacheKey(
repoId: string,
localWorktreeGitOptions: { wslDistro?: string } = {}
): string {
return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}`
}
function warnOnce(keySet: Set<string>, key: string, message: string, error?: unknown): void {
if (keySet.has(key)) {
return
@@ -892,6 +912,7 @@ export function registerWorktreeHandlers(
const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => {
try {
let gitWorktrees
let freshScan = true
if (isFolderRepo(repo)) {
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
@@ -917,13 +938,14 @@ export function registerWorktreeHandlers(
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
} else {
gitWorktrees = await listRepoWorktrees(
repo,
getLocalProjectWorktreeGitOptions(store, repo)
)
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
.filter((worktree) => worktree.visible)
@@ -960,6 +982,7 @@ export function registerWorktreeHandlers(
try {
let gitWorktrees
let freshScan = true
if (isFolderRepo(repo)) {
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
@@ -985,10 +1008,14 @@ export function registerWorktreeHandlers(
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
} else {
gitWorktrees = await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
.filter((worktree) => worktree.visible)
@@ -1443,6 +1470,44 @@ export function registerWorktreeHandlers(
const canonicalWorktreePath = registeredWorktree.path
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
// Why: a prior forced Windows recovery can delete the directory but leave
// Git's stale registration; retry by pruning instead of removing a missing path.
if (
!repo.connectionId &&
args.force === true &&
process.platform === 'win32' &&
(isWindowsAbsolutePathLike(canonicalWorktreePath) ||
!!localWorktreeGitOptions.wslDistro) &&
removedMeta &&
(await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions))
) {
const removalResult = await pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval({
canonicalWorktreePath,
repoPath: repo.path,
localWorktreeGitOptions,
registeredWorktree,
deleteBranch
})
await cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
store,
localWorktreeGitOptions
)
rememberPreservedBranchCleanupTarget(
args.worktreeId,
removalResult,
registeredWorktree.head,
removedPushTarget
)
runtime.clearOptimisticReconcileToken(args.worktreeId)
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
invalidateAuthorizedRootsCache()
notifyWorktreesChanged(mainWindow, repoId)
return removalResult ?? {}
}
let shouldTearDownPtys = true
// Run archive hook before removal so teardown scripts still see the worktree directory.
@@ -1573,8 +1638,22 @@ export function registerWorktreeHandlers(
registeredWorktree.head
)
} catch (error) {
// If git no longer tracks this worktree, clean up the directory and metadata
if (isOrphanedWorktreeError(error)) {
// Why: Git for Windows can fail long-path directory deletion after
// Orca has already validated the target and explicit force delete.
const recoveredRemovalResult = await recoverLocalWindowsLongPathWorktreeRemoval({
error,
force: args.force ?? false,
canonicalWorktreePath,
repoPath: repo.path,
localWorktreeGitOptions,
registeredWorktree,
deleteBranch,
closeWatcher: closeLocalWatcherForRemoval
})
if (recoveredRemovalResult) {
removalResult = recoveredRemovalResult
} else if (isOrphanedWorktreeError(error)) {
// If git no longer tracks this worktree, clean up the directory and metadata
console.warn(
`[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up`
)
@@ -1617,10 +1696,11 @@ export function registerWorktreeHandlers(
invalidateAuthorizedRootsCache()
notifyWorktreesChanged(mainWindow, repoId)
return {}
} else {
throw new Error(
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
)
}
throw new Error(
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
)
}
await cleanupUnusedWorktreePushTargetRemote(
repo.path,
+23 -2
View File
@@ -17,7 +17,11 @@ vi.mock('node:fs/promises', () => ({
rm: rmMock
}))
import { getLocalWorktreePathAccess, removeLocalWorktreePath } from './local-worktree-filesystem'
import {
getLocalWorktreePathAccess,
removeLocalWorktreePath,
toHostRemovalPath
} from './local-worktree-filesystem'
function completeExecFile(stdout = ''): void {
execFileMock.mockImplementation((_file, _args, _options, callback) => {
@@ -59,10 +63,27 @@ describe('local worktree filesystem runtime access', () => {
expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git')
expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8')
expect(rmMock).toHaveBeenCalledWith('C:\\repo\\feature', { recursive: true, force: true })
expect(rmMock).toHaveBeenCalledWith(toHostRemovalPath('C:\\repo\\feature'), {
recursive: true,
force: true
})
expect(execFileMock).not.toHaveBeenCalled()
})
it('uses a Win32 long-path namespace for host removal on Windows', async () => {
await withPlatform('win32', async () => {
const longPath = `C:\\repo\\${'nested\\'.repeat(40)}feature`
await removeLocalWorktreePath(longPath)
expect(toHostRemovalPath(longPath)).toBe(`\\\\?\\${longPath}`)
expect(rmMock).toHaveBeenCalledWith(`\\\\?\\${longPath}`, {
recursive: true,
force: true
})
})
})
it('uses the selected WSL distro for stat, read, and removal on Windows', async () => {
await withPlatform('win32', async () => {
completeExecFile('file')
+9 -2
View File
@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process'
import { lstat, readFile, rm } from 'node:fs/promises'
import { win32 } from 'node:path'
import {
buildWslLoginShellCommand,
escapeWslShCommandForWindows,
@@ -8,7 +9,7 @@ import {
import { toLinuxPath } from './wsl'
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
type LocalWorktreeFilesystemOptions = {
export type LocalWorktreeFilesystemOptions = {
wslDistro?: string
}
@@ -111,7 +112,7 @@ export async function removeLocalWorktreePath(
): Promise<void> {
const distro = options.wslDistro?.trim()
if (!shouldUseWslFilesystem(options) || !distro) {
await rm(targetPath, { recursive: true, force: true })
await rm(toHostRemovalPath(targetPath), { recursive: true, force: true })
return
}
@@ -119,3 +120,9 @@ export async function removeLocalWorktreePath(
// Windows cannot delete safely. Run the deletion inside the selected distro.
await runWslLoginShellCommand(distro, `rm -rf -- ${quotePosixShell(toLinuxPath(targetPath))}`)
}
export function toHostRemovalPath(targetPath: string): string {
// Why: Git for Windows can fail long recursive deletes even after Orca has
// proven the worktree target; Node's host deletion should use Win32 long paths.
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
}
@@ -0,0 +1,94 @@
import type { GitWorktreeInfo, RemoveWorktreeResult } from '../shared/types'
import {
formatWorktreeRemovalError,
isWindowsLongPathWorktreeRemovalError
} from './ipc/worktree-logic'
import { gitExecFileAsync } from './git/runner'
import type { GitWorktreeExecOptions } from './git/worktree'
import { removeLocalWorktreePath } from './local-worktree-filesystem'
type LocalWindowsLongPathRecoveryArgs = {
error: unknown
force: boolean
canonicalWorktreePath: string
repoPath: string
localWorktreeGitOptions: GitWorktreeExecOptions
registeredWorktree: Pick<GitWorktreeInfo, 'branch' | 'head'>
deleteBranch: boolean
closeWatcher: (worktreePath: string) => Promise<void>
}
type StaleLocalWorktreeRegistrationArgs = Omit<
LocalWindowsLongPathRecoveryArgs,
'error' | 'force' | 'closeWatcher'
>
function preservedBranchResult(
registeredWorktree: Pick<GitWorktreeInfo, 'branch' | 'head'>,
deleteBranch: boolean
): RemoveWorktreeResult {
if (!deleteBranch || !registeredWorktree.branch || !registeredWorktree.head) {
return {}
}
return {
preservedBranch: {
branchName: registeredWorktree.branch.replace(/^refs\/heads\//, ''),
head: registeredWorktree.head
}
}
}
async function pruneRequiredGitWorktreeRegistration(
repoPath: string,
localWorktreeGitOptions: GitWorktreeExecOptions,
canonicalWorktreePath: string
): Promise<void> {
try {
await gitExecFileAsync(['worktree', 'prune'], {
cwd: repoPath,
...localWorktreeGitOptions
})
} catch (error) {
throw new Error(
`${formatWorktreeRemovalError(
error,
canonicalWorktreePath,
true
)} The worktree directory was removed, but Git still has stale worktree registration. Retry deletion after resolving the Git prune error.`
)
}
}
export async function recoverLocalWindowsLongPathWorktreeRemoval(
args: LocalWindowsLongPathRecoveryArgs
): Promise<RemoveWorktreeResult | undefined> {
if (!args.force || !isWindowsLongPathWorktreeRemovalError(args.error)) {
return undefined
}
// Why: watcher shutdown is best-effort, but Git registration must be pruned
// before callers clear Orca metadata or the branch remains locked.
await args.closeWatcher(args.canonicalWorktreePath).catch(() => {})
try {
await removeLocalWorktreePath(args.canonicalWorktreePath, args.localWorktreeGitOptions)
} catch (error) {
throw new Error(formatWorktreeRemovalError(error, args.canonicalWorktreePath, true))
}
await pruneRequiredGitWorktreeRegistration(
args.repoPath,
args.localWorktreeGitOptions,
args.canonicalWorktreePath
)
return preservedBranchResult(args.registeredWorktree, args.deleteBranch)
}
export async function pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval(
args: StaleLocalWorktreeRegistrationArgs
): Promise<RemoveWorktreeResult> {
await pruneRequiredGitWorktreeRegistration(
args.repoPath,
args.localWorktreeGitOptions,
args.canonicalWorktreePath
)
return preservedBranchResult(args.registeredWorktree, args.deleteBranch)
}
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
import { MimoCodeHookService } from './hook-service'
describe('MimoCodeHookService buildPtyEnv', () => {
let userDataDir: string
let mimocodeHome: string
beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'orca-mimocode-userdata-'))
getPathMock.mockImplementation((name) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected getPath: ${name}`)
})
mimocodeHome = mkdtempSync(join(tmpdir(), 'orca-mimocode-home-'))
const configDir = join(mimocodeHome, 'config')
mkdirSync(join(configDir, 'plugins'), { recursive: true })
writeFileSync(join(configDir, 'mimocode.json'), '{"theme":"dark"}')
writeFileSync(join(configDir, 'plugins', 'user-plugin.js'), 'export default () => {}')
writeFileSync(join(configDir, 'plugins', 'orca-mimocode-status.js'), 'USER PLUGIN')
})
afterEach(() => {
rmSync(userDataDir, { recursive: true, force: true })
rmSync(mimocodeHome, { recursive: true, force: true })
})
it('mirrors user config into shared overlay and installs Orca status plugin', () => {
const service = new MimoCodeHookService()
const env = service.buildPtyEnv('pty-1', mimocodeHome)
const overlayHome = join(userDataDir, 'mimocode-hooks', 'shared')
expect(env.MIMOCODE_HOME).toBe(overlayHome)
expect(readFileSync(join(overlayHome, 'config', 'mimocode.json'), 'utf8')).toBe(
'{"theme":"dark"}'
)
expect(readFileSync(join(overlayHome, 'config', 'plugins', 'user-plugin.js'), 'utf8')).toBe(
'export default () => {}'
)
const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js')
expect(existsSync(orcaPlugin)).toBe(true)
expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code')
expect(
readFileSync(join(mimocodeHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8')
).toBe('USER PLUGIN')
})
it('reuses the overlay home on a second buildPtyEnv call', () => {
const service = new MimoCodeHookService()
const first = service.buildPtyEnv('pty-1', mimocodeHome)
const second = service.buildPtyEnv('pty-2', mimocodeHome)
const overlayHome = join(userDataDir, 'mimocode-hooks', 'shared')
expect(first.MIMOCODE_HOME).toBe(overlayHome)
expect(second.MIMOCODE_HOME).toBe(overlayHome)
expect(
readFileSync(join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8')
).toContain('/hook/mimo-code')
})
})
+80
View File
@@ -0,0 +1,80 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { getOpenCodeFamilyPluginSource } from '../opencode/hook-service'
import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
const ORCA_MIMOCODE_PLUGIN_FILE = 'orca-mimocode-status.js'
const MIMOCODE_HOOKS_DIR = 'mimocode-hooks'
const MIMOCODE_SHARED_HOME = 'shared'
function defaultMimocodeConfigDir(): string {
return join(homedir(), '.config', 'mimocode')
}
function resolveSourceConfigDir(existingHome: string | undefined): string | undefined {
if (existingHome) {
const fromHome = join(existingHome, 'config')
if (existsSync(fromHome)) {
return fromHome
}
}
const xdg = defaultMimocodeConfigDir()
return existsSync(xdg) ? xdg : undefined
}
function mirrorConfigDir(sourceConfigDir: string, targetConfigDir: string): void {
mkdirSync(targetConfigDir, { recursive: true })
for (const entry of readdirSync(sourceConfigDir, { withFileTypes: true })) {
if (entry.name === 'plugins' && entry.isDirectory()) {
const overlayPlugins = join(targetConfigDir, 'plugins')
mkdirSync(overlayPlugins, { recursive: true })
for (const pluginEntry of readdirSync(join(sourceConfigDir, 'plugins'), {
withFileTypes: true
})) {
if (pluginEntry.name === ORCA_MIMOCODE_PLUGIN_FILE) {
continue
}
mirrorEntry(
join(sourceConfigDir, 'plugins', pluginEntry.name),
join(overlayPlugins, pluginEntry.name)
)
}
continue
}
mirrorEntry(join(sourceConfigDir, entry.name), join(targetConfigDir, entry.name))
}
}
export class MimoCodeHookService {
clearPty(_ptyId: string): void {}
buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record<string, string> {
// Why: MiMo currently uses a shared home; per-source subdirs can come
// later if concurrent MiMo panes need isolated runtime state.
const home = join(app.getPath('userData'), MIMOCODE_HOOKS_DIR, MIMOCODE_SHARED_HOME)
try {
for (const sub of ['config', 'data', 'cache', 'state'] as const) {
mkdirSync(join(home, sub), { recursive: true })
}
const overlayConfig = join(home, 'config')
const sourceConfig = resolveSourceConfigDir(existingMimocodeHome)
if (sourceConfig) {
safeRemoveTree(overlayConfig)
mirrorConfigDir(sourceConfig, overlayConfig)
}
const pluginsDir = join(home, 'config', 'plugins')
mkdirSync(pluginsDir, { recursive: true })
writeFileSync(
join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE),
getOpenCodeFamilyPluginSource('/hook/mimo-code')
)
} catch {
return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {}
}
return { MIMOCODE_HOME: home }
}
}
export const mimoCodeHookService = new MimoCodeHookService()
+6 -2
View File
@@ -58,7 +58,11 @@ function toSafeDirName(id: string): string {
return createHash('sha256').update(id).digest('hex').slice(0, 32)
}
function getOpenCodePluginSource(): string {
export function getOpenCodePluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode')
}
export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
// Why: the plugin runs inside the OpenCode Node process and POSTs to the
// unified agent-hooks server shared with Claude/Codex/Gemini. It reads the
// same ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_AGENT_HOOK_*
@@ -260,7 +264,7 @@ function getOpenCodePluginSource(): string {
' const coords = resolveHookCoords();',
' const paneKey = process.env.ORCA_PANE_KEY;',
' if (!coords.port || !coords.token || !paneKey) return;',
' const url = `http://127.0.0.1:${coords.port}/hook/opencode`;',
` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`,
' const body = JSON.stringify({',
' paneKey,',
' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",',
+12 -3
View File
@@ -1686,11 +1686,15 @@ describe('Store', () => {
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
store.updateAutomationRun({
runId: run.id,
status: 'completed',
status: 'dispatched',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
terminalPaneKey: paneKey,
terminalPtyId: 'pty-run',
outputSnapshot: {
format: 'plain_text',
content: 'Run finished',
@@ -1703,15 +1707,20 @@ describe('Store', () => {
runId: run.id,
status: 'completed',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
usage: null,
error: null
})
expect(store.listAutomationRuns(automation.id)[0].outputSnapshot).toMatchObject({
const persisted = store.listAutomationRuns(automation.id)[0]
expect(persisted.outputSnapshot).toMatchObject({
content: 'Run finished',
truncated: false
})
expect(persisted).toMatchObject({
terminalSessionId: 'tab-1',
terminalPaneKey: paneKey,
terminalPtyId: 'pty-run'
})
})
// ── 3. Corrupt JSON → falls back to defaults ────────────────────────
+61 -1
View File
@@ -72,6 +72,7 @@ import {
normalizeProjectRuntimePreference
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
@@ -665,6 +666,16 @@ function normalizeAutomationRunWorkspaceDisplayName(value: string | null): strin
return trimmed ? trimmed : null
}
function normalizeAutomationRunTerminalPaneKey(value: string | null | undefined): string | null {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed && parsePaneKey(trimmed) ? trimmed : null
}
function normalizeAutomationRunTerminalPtyId(value: string | null | undefined): string | null {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed || null
}
function normalizeAutomationRunOutputSnapshot(
value: AutomationRunOutputSnapshot | null | undefined
): AutomationRunOutputSnapshot | null {
@@ -822,6 +833,14 @@ function backfillLegacyAutomationContexts(
next.sourceContext = automationContexts?.sourceContext ?? null
changed = true
}
if (!Object.hasOwn(next, 'terminalPaneKey')) {
next.terminalPaneKey = null
changed = true
}
if (!Object.hasOwn(next, 'terminalPtyId')) {
next.terminalPtyId = null
changed = true
}
return next
})
if (!changed) {
@@ -1081,6 +1100,24 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
return owner && repo ? { owner, repo } : undefined
}
function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as {
canonicalKey?: unknown
remoteName?: unknown
remoteUrl?: unknown
}
const canonicalKey =
typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : ''
const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : ''
const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl
? { canonicalKey, remoteName, remoteUrl }
: undefined
}
function sanitizeRepoProjectHostSetupMethod(
value: unknown
): RepoProjectHostSetupMethod | undefined {
@@ -1098,6 +1135,7 @@ function sanitizeRepoUpdatesForPersistence<
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
@@ -1130,6 +1168,14 @@ function sanitizeRepoUpdatesForPersistence<
sanitized.upstream = upstream
}
}
if ('gitRemoteIdentity' in sanitized) {
const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity)
if (gitRemoteIdentity === undefined) {
delete sanitized.gitRemoteIdentity
} else {
sanitized.gitRemoteIdentity = gitRemoteIdentity
}
}
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
if (typeof sanitized.worktreeBasePath === 'string') {
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
@@ -3720,6 +3766,7 @@ export class Store {
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'
@@ -3895,6 +3942,7 @@ export class Store {
const {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
gitRemoteIdentity: rawGitRemoteIdentity,
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
@@ -3902,6 +3950,7 @@ export class Store {
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
@@ -3918,6 +3967,7 @@ export class Store {
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
@@ -4118,6 +4168,8 @@ export class Store {
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null,
outputSnapshot: null,
precheckResult: null,
usage: null,
@@ -4153,7 +4205,15 @@ export class Store {
workspaceDisplayName ??
normalizeAutomationRunWorkspaceDisplayName(current.workspaceDisplayName ?? null) ??
this.getAutomationRunWorkspaceDisplayName(workspaceId),
terminalSessionId: result.terminalSessionId ?? current.terminalSessionId,
terminalSessionId: Object.hasOwn(result, 'terminalSessionId')
? (result.terminalSessionId ?? null)
: current.terminalSessionId,
terminalPaneKey: Object.hasOwn(result, 'terminalPaneKey')
? normalizeAutomationRunTerminalPaneKey(result.terminalPaneKey)
: normalizeAutomationRunTerminalPaneKey(current.terminalPaneKey),
terminalPtyId: Object.hasOwn(result, 'terminalPtyId')
? normalizeAutomationRunTerminalPtyId(result.terminalPtyId)
: normalizeAutomationRunTerminalPtyId(current.terminalPtyId),
outputSnapshot: Object.hasOwn(result, 'outputSnapshot')
? normalizeAutomationRunOutputSnapshot(result.outputSnapshot)
: normalizeAutomationRunOutputSnapshot(current.outputSnapshot),
@@ -30,6 +30,15 @@ describe('stripTerminalControls', () => {
it('drops non-printable bytes but keeps whitespace and printable ASCII', () => {
expect(stripTerminalControls('a\x00b\x08c\td')).toBe('abc\td')
})
it('turns horizontal cursor moves into a URL-invalid guard', () => {
// A differential redraw steps the cursor over the on-screen `o` instead of
// reprinting it. Deleting the move would splice `localh` + `st` into `localhst`.
expect(stripTerminalControls('http://localh\x1b[1Cst:5199/')).toBe('http://localh[st:5199/')
// Absolute column (G) and position (H) moves are neutralized the same way.
expect(stripTerminalControls('localh\x1b[8Gst')).toBe('localh[st')
expect(stripTerminalControls('localh\x1b[1;9Hst')).toBe('localh[st')
})
})
describe('extractUrlCandidates', () => {
@@ -57,6 +66,21 @@ describe('extractUrlCandidates', () => {
expect(extractUrlCandidates('example.com:3001')).toHaveLength(0)
})
it('does not capture a corrupted host from a cursor-skip redraw', () => {
// Regression: a CLI redrawing `http://localhost:5199/` via a cursor-forward
// over the already-drawn `o` must not yield the plausible-but-wrong
// `localhst` host that `new URL()` would otherwise accept verbatim.
const cleaned = stripTerminalControls(' > Local: http://localh\x1b[1Cst:5199/\r\n')
const urls = extractUrlCandidates(cleaned)
expect(urls).toHaveLength(0)
})
it('does not cache a partial default-port URL from a cursor-skip redraw', () => {
const watcher = bindFresh()
watcher.ingest(PTY, 'Local: http://localh\x1b[1Cst/\n')
expect(watcher.lookup(WORKTREE, 80)).toBeUndefined()
})
it('handles multiple URLs in one line', () => {
const urls = extractUrlCandidates(
'Local: http://localhost:3001/ Network: https://custom:3001/'
+7 -2
View File
@@ -21,9 +21,13 @@ const MAX_PENDING_ENTRIES = 32
const MAX_CACHE_ENTRIES = 256
const URL_CANDIDATE_LIMIT = 2048
// ANSI/OSC strippers mirror normalizeTerminalChunk in
// src/main/runtime/orca-runtime.ts so the two stay in lockstep.
// ANSI/OSC strippers mirror the runtime normalizer, with URL-specific cursor
// move handling below to avoid fusing text that a real terminal would skip.
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
// Why: cursor moves in differential redraws can skip cells that are already on
// screen. Replacing them with a URL-invalid guard skips the damaged candidate.
const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g
const CURSOR_MOVE_URL_GUARD = '['
const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g
const SINGLE_ESC_PATTERN = /\x1b[@-_]/g
const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g
@@ -117,6 +121,7 @@ export function stripTerminalControls(text: string): string {
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(OSC_PATTERN, '')
.replace(CURSOR_MOVE_PATTERN, CURSOR_MOVE_URL_GUARD)
.replace(CSI_PATTERN, '')
.replace(SINGLE_ESC_PATTERN, '')
.replace(CONTROL_PATTERN, '')
@@ -10,6 +10,7 @@ describe('PowerShell OSC 133 bootstrap', () => {
expect(script).toContain('[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()')
expect(script).toContain('ORCA_OPENCODE_CONFIG_DIR')
expect(script).toContain('ORCA_MIMOCODE_HOME')
expect(script).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(script).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(script).toContain('ORCA_OMP_STATUS_EXTENSION')
+1
View File
@@ -24,6 +24,7 @@ try {
# Profiles can re-export user defaults after Orca's spawn env is set.
if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR }
if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME }
${getPowerShellOmpShellWrapper()}
if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME }
@@ -4,6 +4,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
import {
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath,
inferFolderWorkspacePathConnection
} from './folder-workspace-path-status'
@@ -185,6 +186,25 @@ describe('folder workspace path status', () => {
).toEqual({ kind: 'ambiguous' })
})
it('supports direct path scope without a persisted project group', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
} as unknown as IFilesystemProvider
await expect(
getFolderWorkspacePathStatus(
{
getRepos: () => [makeRepo({ connectionId: 'ssh-1' })],
getProjectGroups: () => [],
getFolderWorkspaces: () => []
},
{ scope: 'path', path: '/workspace/platform', connectionId: 'ssh-1' },
{ getSshFilesystemProvider: () => provider }
)
).resolves.toEqual({ path: '/workspace/platform', exists: true })
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
})
it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
@@ -25,18 +25,22 @@ type FolderWorkspacePathStatusDeps = {
function getFolderScopeCandidateRepos(args: {
folderPath: string
projectGroupId: string
projectGroupId?: string | null
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): Repo[] {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const groupIds = args.projectGroupId
? getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
: null
const groupRepos = groupIds
? args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
: []
const pathRepos = args.repos.filter(
(repo) =>
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
!(groupIds && typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
isPathInsideOrEqual(args.folderPath, repo.path)
)
if (args.connectionId) {
@@ -57,7 +61,7 @@ function getFolderScopeCandidateRepos(args: {
export function inferFolderWorkspacePathConnection(args: {
folderPath: string
projectGroupId: string
projectGroupId?: string | null
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
@@ -134,7 +138,7 @@ async function statFolderPath(
export async function getFolderWorkspacePathStatusForPath(
args: {
folderPath: string
projectGroupId: string
projectGroupId?: string | null
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
@@ -148,7 +152,7 @@ export async function getFolderWorkspacePathStatusForPath(
export function resolveFolderWorkspaceStatusPath(args: {
store: FolderWorkspacePathStatusStore
request: FolderWorkspacePathStatusRequest
}): { folderPath: string; projectGroupId: string; connectionId?: string | null } {
}): { folderPath: string; projectGroupId: string | null; connectionId?: string | null } {
const { request } = args
if (request.scope === 'project-group') {
const group = args.store
@@ -164,6 +168,14 @@ export function resolveFolderWorkspaceStatusPath(args: {
}
}
if (request.scope === 'path') {
return {
folderPath: request.path,
projectGroupId: null,
connectionId: request.connectionId ?? null
}
}
const workspace = args.store
.getFolderWorkspaces?.()
.find((entry) => entry.id === request.folderWorkspaceId)
@@ -0,0 +1,116 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { join } from 'path'
import type { GitWorktreeInfo } from '../../shared/types'
import { listWorktreeGraph } from '../git/worktree'
import {
createNestedRepoImportTargetResolver,
resolveLocalNestedRepoImportTargetPath,
resolveSshNestedRepoImportTargetPath
} from './nested-repo-import-target'
vi.mock('../git/worktree', () => ({
listWorktreeGraph: vi.fn()
}))
function worktree(overrides: Partial<GitWorktreeInfo>): GitWorktreeInfo {
return {
path: join('/workspace', 'repo'),
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
...overrides
}
}
describe('nested repo import target resolution', () => {
beforeEach(() => {
vi.mocked(listWorktreeGraph).mockReset()
})
it('canonicalizes a selected linked worktree to its non-bare main worktree', async () => {
const mainPath = join('/workspace', 'source', 'demo')
const selectedPath = `${join('/workspace', 'paseo', 'demo', 'brash-binder')}/`
const selectedPathInGraph = join('/workspace', 'paseo', 'demo', 'brash-binder')
vi.mocked(listWorktreeGraph).mockResolvedValue([
worktree({ path: mainPath, isMainWorktree: true }),
worktree({ path: selectedPathInGraph, branch: 'refs/heads/brash-binder' })
])
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(mainPath)
expect(listWorktreeGraph).toHaveBeenCalledWith(selectedPath)
})
it('warms the local resolver cache for sibling worktrees in the same graph', async () => {
const mainPath = join('/workspace', 'source', 'demo')
const firstPath = join('/workspace', 'paseo', 'demo', 'brash-binder')
const secondPath = join('/workspace', 'paseo', 'demo', 'quick-howler')
vi.mocked(listWorktreeGraph).mockResolvedValue([
worktree({ path: mainPath, isMainWorktree: true }),
worktree({ path: firstPath, branch: 'refs/heads/brash-binder' }),
worktree({ path: secondPath, branch: 'refs/heads/quick-howler' })
])
const resolver = createNestedRepoImportTargetResolver()
await expect(resolver.resolveLocal(firstPath)).resolves.toBe(mainPath)
await expect(resolver.resolveLocal(secondPath)).resolves.toBe(mainPath)
expect(listWorktreeGraph).toHaveBeenCalledTimes(1)
})
it('falls back when the worktree list is empty', async () => {
const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder')
vi.mocked(listWorktreeGraph).mockResolvedValue([])
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath)
})
it('falls back when the worktree lister throws', async () => {
const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder')
vi.mocked(listWorktreeGraph).mockRejectedValue(new Error('git failed'))
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath)
})
it('falls back when Git returns a stale graph that omits the selected path', async () => {
const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder')
vi.mocked(listWorktreeGraph).mockResolvedValue([
worktree({ path: join('/other', 'source', 'demo'), isMainWorktree: true }),
worktree({ path: join('/other', 'linked', 'quick-howler') })
])
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath)
})
it('falls back when the only main worktree is bare', async () => {
const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder')
vi.mocked(listWorktreeGraph).mockResolvedValue([
worktree({
path: join('/workspace', 'source', 'demo.git'),
isBare: true,
isMainWorktree: true
}),
worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' })
])
await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath)
})
it('uses the SSH provider worktree list for remote import targets', async () => {
const mainPath = join('/srv', 'source', 'demo')
const selectedPath = join('/srv', 'paseo', 'demo', 'brash-binder')
const gitProvider = {
listWorktrees: vi
.fn()
.mockResolvedValue([
worktree({ path: mainPath, isMainWorktree: true }),
worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' })
])
}
await expect(resolveSshNestedRepoImportTargetPath(selectedPath, gitProvider)).resolves.toBe(
mainPath
)
expect(gitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath)
expect(listWorktreeGraph).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,87 @@
import type { GitWorktreeInfo } from '../../shared/types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { listWorktreeGraph } from '../git/worktree'
type WorktreeLister = {
listWorktrees: (repoPath: string) => Promise<GitWorktreeInfo[]>
}
export type NestedRepoImportTargetResolver = {
resolveLocal: (repoPath: string) => Promise<string>
resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise<string>
}
function findImportTarget(
selectedPath: string,
worktrees: readonly GitWorktreeInfo[]
): { targetPath: string; graphPaths: string[] } | null {
const selectedPathKey = normalizeRuntimePathForComparison(selectedPath)
const graphContainsSelectedPath = worktrees.some(
(worktree) => normalizeRuntimePathForComparison(worktree.path) === selectedPathKey
)
if (!graphContainsSelectedPath) {
return null
}
// Why: a linked worktree may only collapse to its owner when Git proves the
// selected path belongs to that same non-bare worktree graph.
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree && !worktree.isBare)
return mainWorktree
? { targetPath: mainWorktree.path, graphPaths: worktrees.map((worktree) => worktree.path) }
: null
}
async function resolveWithCache(
repoPath: string,
cache: Map<string, string>,
readWorktreeGraph: (path: string) => Promise<GitWorktreeInfo[]>
): Promise<string> {
const repoPathKey = normalizeRuntimePathForComparison(repoPath)
const cachedPath = cache.get(repoPathKey)
if (cachedPath) {
return cachedPath
}
try {
const target = findImportTarget(repoPath, await readWorktreeGraph(repoPath))
if (target) {
for (const graphPath of target.graphPaths) {
cache.set(normalizeRuntimePathForComparison(graphPath), target.targetPath)
}
return target.targetPath
}
} catch {
// Fall through to selected-path compatibility behavior.
}
cache.set(repoPathKey, repoPath)
return repoPath
}
export function createNestedRepoImportTargetResolver(): NestedRepoImportTargetResolver {
const localCache = new Map<string, string>()
const sshCaches = new WeakMap<WorktreeLister, Map<string, string>>()
return {
resolveLocal: (repoPath) =>
resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path)),
resolveSsh: (repoPath, gitProvider) => {
let cache = sshCaches.get(gitProvider)
if (!cache) {
cache = new Map()
sshCaches.set(gitProvider, cache)
}
return resolveWithCache(repoPath, cache, (path) => gitProvider.listWorktrees(path))
}
}
}
export async function resolveLocalNestedRepoImportTargetPath(repoPath: string): Promise<string> {
return createNestedRepoImportTargetResolver().resolveLocal(repoPath)
}
export async function resolveSshNestedRepoImportTargetPath(
repoPath: string,
gitProvider: WorktreeLister
): Promise<string> {
return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider)
}
+74 -1
View File
@@ -61,6 +61,7 @@ vi.mock('../wsl', () => ({
}))
import { LocalPtyProvider } from './local-pty-provider'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
@@ -75,13 +76,16 @@ describe('LocalPtyProvider', () => {
}
let exitCb: ((info: { exitCode: number }) => void) | undefined
let origShell: string | undefined
let origPowerlevelWizardDisable: string | undefined
let origPlatform: PropertyDescriptor | undefined
beforeEach(() => {
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
origShell = process.env.SHELL
origPowerlevelWizardDisable = process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
process.env.SHELL = '/bin/zsh'
delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
@@ -128,6 +132,11 @@ describe('LocalPtyProvider', () => {
} else {
process.env.SHELL = origShell
}
if (origPowerlevelWizardDisable === undefined) {
delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
} else {
process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD = origPowerlevelWizardDisable
}
})
describe('spawn', () => {
@@ -198,6 +207,35 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => {
await provider.spawn({ cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('true')
})
it('preserves an explicit Powerlevel10k wizard env value', async () => {
await provider.spawn({
cols: 80,
rows: 24,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'already-set' }
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('already-set')
})
it('honors requests to delete the Powerlevel10k wizard env value', async () => {
await provider.spawn({
cols: 80,
rows: 24,
envToDelete: ['POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBeUndefined()
})
it('uses fallback shell readiness when startup-command shell spawn falls back', async () => {
vi.useFakeTimers()
try {
@@ -307,6 +345,23 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('uses shell wrapper when MiMo home must survive shell startup', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.MIMOCODE_HOME = '/tmp/orca-mimocode-overlay'
env.ORCA_MIMOCODE_HOME = '/tmp/orca-mimocode-overlay'
return env
}
})
await provider.spawn({ cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[1]).toEqual(['-l'])
expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/)
expect(spawnCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('does not pass a Windows Codex home into WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
@@ -489,7 +544,25 @@ describe('LocalPtyProvider', () => {
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(spawnCall[2].env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
expect(spawnCall[2].env.WSLENV).toBe(
'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
)
})
it('does not mark deleted Powerlevel10k wizard env for WSL import', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBeUndefined()
expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV)
})
it('does not inherit parent Orca pane identity when caller omits pane env', async () => {
+13
View File
@@ -35,6 +35,10 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { addWslEnvKeys } from '../wsl-env'
import {
POWERLEVEL10K_WIZARD_DISABLE_ENV,
seedPowerlevel10kWizardEnv
} from '../pty/powerlevel10k-wizard-env'
import {
isWindowsGitBashShellPath,
resolveGitBashPath,
@@ -533,12 +537,21 @@ export class LocalPtyProvider implements IPtyProvider {
delete finalEnv.ORCA_CODEX_HOME
}
}
seedPowerlevel10kWizardEnv(finalEnv, { envToDelete: args.envToDelete })
if (
finalEnv[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
process.platform === 'win32' &&
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
) {
addWslEnvKeys(finalEnv, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
if (!wslInfo && process.platform !== 'win32') {
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
const needsNoMarkerWrapper =
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_MIMOCODE_HOME ||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
@@ -437,6 +437,8 @@ describePosix('local PTY shell-ready launch config', () => {
const bashRc = getBashShellReadyRcfileContent()
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const mimoRestoreLine =
'[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
@@ -444,6 +446,9 @@ describePosix('local PTY shell-ready launch config', () => {
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(mimoRestoreLine)
expect(zlogin).toContain(mimoRestoreLine)
expect(bashRc).toContain(mimoRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
@@ -140,6 +140,7 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@@ -255,6 +256,7 @@ __orca_restore_agent_teams_path() {
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@@ -318,6 +320,7 @@ __orca_restore_agent_teams_path() {
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
+274
View File
@@ -31,6 +31,14 @@ async function waitForRequestCount(mock: ReturnType<typeof vi.fn>, count: number
}
}
function deferredValue<T>(value: T): { promise: Promise<T>; resolve: () => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve: () => resolve(value) }
}
describe('SshGitProvider', () => {
let mux: MockMultiplexer
let provider: SshGitProvider
@@ -828,6 +836,272 @@ describe('SshGitProvider', () => {
expect(result).toEqual(diffs)
})
it('coalesces concurrent identical diff RPCs while in flight', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
const pendingDiff = deferredValue(diff)
mux.request.mockReturnValue(pendingDiff.promise)
const reads = Array.from({ length: 8 }, () =>
provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
)
await waitForRequestCount(mux.request, 1)
expect(mux.request).toHaveBeenCalledTimes(1)
pendingDiff.resolve()
await expect(Promise.all(reads)).resolves.toEqual(Array(8).fill(diff))
mux.request.mockReset()
const branchDiffs = [diff]
const pendingBranchDiff = deferredValue(branchDiffs)
mux.request.mockReturnValue(pendingBranchDiff.promise)
const branchReads = Array.from({ length: 8 }, () =>
provider.getBranchDiff('/home/user/repo', 'main', {
includePatch: true,
filePath: 'src/file.ts'
})
)
await waitForRequestCount(mux.request, 1)
expect(mux.request).toHaveBeenCalledTimes(1)
pendingBranchDiff.resolve()
await expect(Promise.all(branchReads)).resolves.toEqual(Array(8).fill(branchDiffs))
mux.request.mockReset()
const pendingCommitDiff = deferredValue(diff)
mux.request.mockReturnValue(pendingCommitDiff.promise)
const commitReads = Array.from({ length: 8 }, () =>
provider.getCommitDiff('/home/user/repo', {
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts'
})
)
await waitForRequestCount(mux.request, 1)
expect(mux.request).toHaveBeenCalledTimes(1)
pendingCommitDiff.resolve()
await expect(Promise.all(commitReads)).resolves.toEqual(Array(8).fill(diff))
})
it('retries diff RPCs after an in-flight rejection settles', async () => {
const failure = new Error('transient relay failure')
mux.request.mockRejectedValueOnce(failure)
const firstBurst = Array.from({ length: 8 }, () =>
provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
)
await expect(Promise.all(firstBurst)).rejects.toThrow('transient relay failure')
expect(mux.request).toHaveBeenCalledTimes(1)
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
mux.request.mockResolvedValueOnce(diff)
await expect(provider.getDiff('/home/user/repo', 'src/file.ts', false, true)).resolves.toBe(
diff
)
expect(mux.request).toHaveBeenCalledTimes(2)
})
it('clears pending diff RPCs when status runs', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
const pendingDiff = deferredValue(diff)
mux.request.mockReturnValueOnce(pendingDiff.promise)
const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
await waitForRequestCount(mux.request, 1)
mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' })
await provider.getStatus('/home/user/repo')
mux.request.mockResolvedValueOnce(diff)
const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
pendingDiff.resolve()
await expect(Promise.all([first, second])).resolves.toEqual([diff, diff])
expect(mux.request).toHaveBeenCalledTimes(3)
})
it('clears pending diff RPCs when a mutation runs', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
const pendingDiff = deferredValue(diff)
mux.request.mockReturnValueOnce(pendingDiff.promise)
const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
await waitForRequestCount(mux.request, 1)
mux.request.mockResolvedValueOnce(undefined)
await provider.stageFile('/home/user/repo', 'src/file.ts')
mux.request.mockResolvedValueOnce(diff)
const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
pendingDiff.resolve()
await expect(Promise.all([first, second])).resolves.toEqual([diff, diff])
expect(mux.request).toHaveBeenCalledTimes(3)
})
it('clears pending branch diff RPCs when a ref-moving provider operation runs', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
const pendingDiff = deferredValue([diff])
mux.request.mockReturnValueOnce(pendingDiff.promise)
const first = provider.getBranchDiff('/home/user/repo', 'origin/main', {
includePatch: true,
filePath: 'src/file.ts'
})
await waitForRequestCount(mux.request, 1)
mux.request.mockResolvedValueOnce(undefined)
await provider.fetchRemoteTrackingRef(
'/home/user/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
mux.request.mockResolvedValueOnce([diff])
const second = provider.getBranchDiff('/home/user/repo', 'origin/main', {
includePatch: true,
filePath: 'src/file.ts'
})
pendingDiff.resolve()
await expect(Promise.all([first, second])).resolves.toEqual([[diff], [diff]])
expect(mux.request).toHaveBeenCalledTimes(3)
})
it('coalesces logically identical branch and commit diff RPC args regardless of property order', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
mux.request.mockResolvedValue([diff])
await Promise.all([
provider.getBranchDiff('/home/user/repo', 'main', {
includePatch: true,
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
provider.getBranchDiff('/home/user/repo', 'main', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
includePatch: true
})
])
expect(mux.request).toHaveBeenCalledTimes(1)
mux.request.mockReset()
mux.request.mockResolvedValue(diff)
await Promise.all([
provider.getCommitDiff('/home/user/repo', {
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
provider.getCommitDiff('/home/user/repo', {
oldPath: 'src/old-file.ts',
filePath: 'src/file.ts',
parentOid: 'b'.repeat(40),
commitOid: 'c'.repeat(40)
})
])
expect(mux.request).toHaveBeenCalledTimes(1)
})
it('keeps distinct diff RPC keys independent', async () => {
const diff = {
kind: 'text',
originalContent: 'old',
modifiedContent: 'new',
originalIsBinary: false,
modifiedIsBinary: false
}
mux.request.mockResolvedValue(diff)
await Promise.all([
provider.getDiff('/home/user/repo', 'src/file.ts', false, false),
provider.getDiff('/home/user/repo', 'src/file.ts', true, false),
provider.getDiff('/home/user/repo', 'src/file.ts', false, true),
provider.getBranchDiff('/home/user/repo', 'main', {
includePatch: true,
filePath: 'src/file.ts'
}),
provider.getBranchDiff('/home/user/repo', 'main', {
includePatch: false,
filePath: 'src/file.ts'
}),
provider.getBranchDiff('/home/user/repo', 'main', {
includePatch: true,
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
}),
provider.getBranchDiff('/home/user/repo', 'develop', {
includePatch: true,
filePath: 'src/file.ts'
}),
provider.getCommitDiff('/home/user/repo', {
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts'
}),
provider.getCommitDiff('/home/user/repo', {
commitOid: 'c'.repeat(40),
parentOid: 'a'.repeat(40),
filePath: 'src/file.ts'
}),
provider.getCommitDiff('/home/user/repo', {
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
])
expect(mux.request).toHaveBeenCalledTimes(10)
})
it('listWorktrees sends git.listWorktrees request', async () => {
const worktrees = [
{
+171 -64
View File
@@ -28,6 +28,7 @@ import {
describeMaxBufferOverflowError,
isMaxBufferOverflowError
} from '../git/max-buffer-overflow'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
type NonInteractiveExecQueueEntry = {
started: boolean
@@ -58,9 +59,22 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un
}
export class SshGitProvider implements IGitProvider {
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult | GitDiffResult[]>()
private connectionId: string
private mux: SshChannelMultiplexer
private nonInteractiveExecQueues = new Map<string, NonInteractiveExecQueueEntry[]>()
private async runWithDiffDedupeClear<T>(run: () => Promise<T>): Promise<T> {
// Why: git mutations can stale both existing and concurrently-started diff reads.
// Clear before and after so later reads never join pre-mutation work.
this.gitDiffReadDedupe.clear()
try {
return await run()
} finally {
this.gitDiffReadDedupe.clear()
}
}
private loggedWorktreeIsCleanFallback = false
constructor(
@@ -84,6 +98,7 @@ export class SshGitProvider implements IGitProvider {
worktreePath: string,
options?: GitProviderStatusOptions
): Promise<GitStatusResult> {
this.gitDiffReadDedupe.clear()
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
@@ -116,10 +131,13 @@ export class SshGitProvider implements IGitProvider {
worktreePath: string,
message: string
): Promise<{ success: boolean; error?: string }> {
return (await this.mux.request('git.commit', {
worktreePath,
message
})) as { success: boolean; error?: string }
return this.runWithDiffDedupeClear(
async () =>
(await this.mux.request('git.commit', {
worktreePath,
message
})) as { success: boolean; error?: string }
)
}
async getStagedCommitContext(worktreePath: string): Promise<CommitMessageDraftContext | null> {
@@ -321,36 +339,70 @@ export class SshGitProvider implements IGitProvider {
staged: boolean,
compareAgainstHead?: boolean
): Promise<GitDiffResult> {
return (await this.mux.request('git.diff', {
worktreePath,
filePath,
staged,
compareAgainstHead
})) as GitDiffResult
return this.gitDiffReadDedupe.run(
stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]),
async () =>
(await this.mux.request('git.diff', {
worktreePath,
filePath,
staged,
compareAgainstHead
})) as GitDiffResult
) as Promise<GitDiffResult>
}
async stageFile(worktreePath: string, filePath: string): Promise<void> {
await this.mux.request('git.stage', { worktreePath, filePath })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.stage', { worktreePath, filePath })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async unstageFile(worktreePath: string, filePath: string): Promise<void> {
await this.mux.request('git.unstage', { worktreePath, filePath })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.unstage', { worktreePath, filePath })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise<void> {
await this.mux.request('git.bulkStage', { worktreePath, filePaths })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.bulkStage', { worktreePath, filePaths })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise<void> {
await this.mux.request('git.bulkUnstage', { worktreePath, filePaths })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.bulkUnstage', { worktreePath, filePaths })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async discardChanges(worktreePath: string, filePath: string): Promise<void> {
await this.mux.request('git.discard', { worktreePath, filePath })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.discard', { worktreePath, filePath })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise<void> {
await this.mux.request('git.bulkDiscard', { worktreePath, filePaths })
this.gitDiffReadDedupe.clear()
try {
await this.mux.request('git.bulkDiscard', { worktreePath, filePaths })
} finally {
this.gitDiffReadDedupe.clear()
}
}
async detectConflictOperation(worktreePath: string): Promise<GitConflictOperation> {
@@ -360,15 +412,21 @@ export class SshGitProvider implements IGitProvider {
}
async abortMerge(worktreePath: string): Promise<void> {
await this.mux.request('git.abortMerge', { worktreePath })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.abortMerge', { worktreePath })
})
}
async abortRebase(worktreePath: string): Promise<void> {
await this.mux.request('git.abortRebase', { worktreePath })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.abortRebase', { worktreePath })
})
}
async checkoutBranch(worktreePath: string, branch: string): Promise<void> {
await this.mux.request('git.checkout', { worktreePath, branch })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.checkout', { worktreePath, branch })
})
}
async listLocalBranches(
@@ -410,41 +468,54 @@ export class SshGitProvider implements IGitProvider {
pushTarget?: GitPushTarget,
options: { forceWithLease?: boolean } = {}
): Promise<void> {
await this.mux.request('git.push', {
worktreePath,
publish,
pushTarget,
...(options.forceWithLease === true ? { forceWithLease: true } : {})
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.push', {
worktreePath,
publish,
pushTarget,
...(options.forceWithLease === true ? { forceWithLease: true } : {})
})
})
}
async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise<void> {
await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) })
})
}
async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise<void> {
await this.mux.request('git.fastForward', {
worktreePath,
...(pushTarget ? { pushTarget } : {})
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.fastForward', {
worktreePath,
...(pushTarget ? { pushTarget } : {})
})
})
}
async rebaseFromBase(worktreePath: string, baseRef: string): Promise<void> {
await this.mux.request('git.rebaseFromBase', { worktreePath, baseRef })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.rebaseFromBase', { worktreePath, baseRef })
})
}
async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise<void> {
await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) })
})
}
async syncForkDefaultBranch(
worktreePath: string,
expectedUpstream: GitForkSyncExpectedUpstream
): Promise<GitForkSyncResult> {
return (await this.mux.request('git.forkSync', {
worktreePath,
...(expectedUpstream ? { expectedUpstream } : {})
})) as GitForkSyncResult
return this.runWithDiffDedupeClear(
async () =>
(await this.mux.request('git.forkSync', {
worktreePath,
...(expectedUpstream ? { expectedUpstream } : {})
})) as GitForkSyncResult
)
}
async fetchRemoteTrackingRef(
@@ -453,11 +524,13 @@ export class SshGitProvider implements IGitProvider {
branch: string,
ref: string
): Promise<void> {
await this.mux.request('git.fetchRemoteTrackingRef', {
worktreePath,
remote,
branch,
ref
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.fetchRemoteTrackingRef', {
worktreePath,
remote,
branch,
ref
})
})
}
@@ -466,10 +539,12 @@ export class SshGitProvider implements IGitProvider {
remote: string,
mrIid: number
): Promise<void> {
await this.mux.request('git.fetchGitLabMergeRequestHead', {
worktreePath,
remote,
mrIid
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.fetchGitLabMergeRequestHead', {
worktreePath,
remote,
mrIid
})
})
}
@@ -478,21 +553,44 @@ export class SshGitProvider implements IGitProvider {
baseRef: string,
options?: { includePatch?: boolean; filePath?: string; oldPath?: string }
): Promise<GitDiffResult[]> {
return (await this.mux.request('git.branchDiff', {
worktreePath,
baseRef,
...options
})) as GitDiffResult[]
const keyOptions = options ?? {}
return this.gitDiffReadDedupe.run(
stableInFlightKey([
'branchDiff',
worktreePath,
baseRef,
keyOptions.includePatch ?? null,
keyOptions.filePath ?? null,
keyOptions.oldPath ?? null
]),
async () =>
(await this.mux.request('git.branchDiff', {
worktreePath,
baseRef,
...options
})) as GitDiffResult[]
) as Promise<GitDiffResult[]>
}
async getCommitDiff(
worktreePath: string,
args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string }
): Promise<GitDiffResult> {
return (await this.mux.request('git.commitDiff', {
worktreePath,
...args
})) as GitDiffResult
return this.gitDiffReadDedupe.run(
stableInFlightKey([
'commitDiff',
worktreePath,
args.commitOid,
args.parentOid ?? null,
args.filePath,
args.oldPath ?? null
]),
async () =>
(await this.mux.request('git.commitDiff', {
worktreePath,
...args
})) as GitDiffResult
) as Promise<GitDiffResult>
}
async listWorktrees(
@@ -514,11 +612,13 @@ export class SshGitProvider implements IGitProvider {
targetDir: string,
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
): Promise<void> {
await this.mux.request('git.addWorktree', {
repoPath,
branchName,
targetDir,
...options
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.addWorktree', {
repoPath,
branchName,
targetDir,
...options
})
})
}
@@ -527,11 +627,14 @@ export class SshGitProvider implements IGitProvider {
force?: boolean,
options?: { deleteBranch?: boolean; forceBranchDelete?: boolean }
): Promise<RemoveWorktreeResult> {
return ((await this.mux.request('git.removeWorktree', {
worktreePath,
force,
...options
})) ?? {}) as RemoveWorktreeResult
return this.runWithDiffDedupeClear(
async () =>
((await this.mux.request('git.removeWorktree', {
worktreePath,
force,
...options
})) ?? {}) as RemoveWorktreeResult
)
}
async worktreeIsClean(
@@ -583,11 +686,15 @@ export class SshGitProvider implements IGitProvider {
ownerWorktreePath?: string
checkOnly?: boolean
}): Promise<void> {
await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args)
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args)
})
}
async renameCurrentBranch(worktreePath: string, newBranch: string): Promise<void> {
await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch })
await this.runWithDiffDedupeClear(async () => {
await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch })
})
}
async exec(
+41 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
@@ -43,7 +44,7 @@ describe('SshPtyProvider', () => {
cols: 80,
rows: 24,
cwd: undefined,
env: undefined
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
})
expect(result).toEqual({ id: scopedPty1 })
})
@@ -62,7 +63,42 @@ describe('SshPtyProvider', () => {
cols: 120,
rows: 40,
cwd: '/home/user',
env: { FOO: 'bar' }
env: { FOO: 'bar', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
})
})
it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
})
})
it('honors requests to delete the remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' },
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {}
})
})
@@ -88,6 +124,7 @@ describe('SshPtyProvider', () => {
env: {
PATH: '/home/user/.orca-relay/bin:/usr/bin',
ORCA_TERMINAL_HANDLE: 'term_ssh',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
@@ -117,6 +154,7 @@ describe('SshPtyProvider', () => {
cwd: undefined,
env: {
ORCA_TERMINAL_HANDLE: 'term_ssh',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
@@ -147,6 +185,7 @@ describe('SshPtyProvider', () => {
cwd: undefined,
env: {
Path: 'C:/Users/me/.orca-relay/bin;C:/Windows/System32;C:/Tools',
[POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
ORCA_REMOTE_CLI_BIN_DIR: 'C:/Users/me/.orca-relay/bin',
ORCA_RELAY_DIR: 'C:/Users/me/.orca-remote/relay-v1',
ORCA_RELAY_NODE_PATH: 'C:/Program Files/nodejs/node.exe',
+12 -6
View File
@@ -1,6 +1,7 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
type DataCallback = (payload: { id: string; data: string }) => void
type ReplayCallback = (payload: { id: string; data: string }) => void
@@ -130,7 +131,7 @@ export class SshPtyProvider implements IPtyProvider {
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env: this.withRemoteCliBridgeEnv(opts.env),
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
// Why: the relay's plugin-overlay env augmenter needs to know which
// Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors
// the right `~/.<kind>/agent` source dir on the remote disk. The
@@ -150,12 +151,17 @@ export class SshPtyProvider implements IPtyProvider {
}
private withRemoteCliBridgeEnv(
env: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!this.remoteCliBridgeEnv) {
return env
}
env: Record<string, string> | undefined,
envToDelete?: readonly string[]
): Record<string, string> {
const merged = { ...env }
for (const key of envToDelete ?? []) {
delete merged[key]
}
seedPowerlevel10kWizardEnv(merged, { envToDelete })
if (!this.remoteCliBridgeEnv) {
return merged
}
const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':'
const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null
if (pathKey) {
+13
View File
@@ -0,0 +1,13 @@
export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
export function seedPowerlevel10kWizardEnv(
env: Record<string, string>,
options: { envToDelete?: readonly string[] } = {}
): void {
if (options.envToDelete?.includes(POWERLEVEL10K_WIZARD_DISABLE_ENV)) {
return
}
// Why: p10k's first-run wizard blocks shell startup and queued commands.
// Users can still run `p10k configure` manually inside an Orca terminal.
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] ??= 'true'
}
+71 -1
View File
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileSyncMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
@@ -26,6 +28,8 @@ function setPlatform(platform: NodeJS.Platform): () => void {
describe('isPwshAvailable', () => {
beforeEach(() => {
vi.resetModules()
vi.useRealTimers()
execFileMock.mockReset()
execFileSyncMock.mockReset()
})
@@ -84,4 +88,70 @@ describe('isPwshAvailable', () => {
restorePlatform()
}
})
it('repro: does not keep a cold-start timeout cached for the daemon lifetime', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
const error = Object.assign(new Error('spawnSync pwsh.exe ETIMEDOUT'), {
code: 'ETIMEDOUT'
})
throw error
})
.mockReturnValue('PowerShell 7.5.0')
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
}
})
it('warms pwsh availability asynchronously with a longer timeout', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(null, 'PowerShell 7.5.0', '')
})
try {
const { isPwshAvailable, warmPwshAvailabilityCache } = await import('./pwsh')
await expect(warmPwshAvailabilityCache()).resolves.toBe(true)
expect(execFileMock).toHaveBeenCalledWith(
'pwsh.exe',
['-Version'],
{ timeout: 30_000 },
expect.any(Function)
)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
})
it('retries non-timeout failures after the negative cache TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
throw new Error('missing pwsh')
})
.mockReturnValue('PowerShell 7.5.0')
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(isPwshAvailable()).toBe(false)
vi.setSystemTime(31_001)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
vi.useRealTimers()
}
})
})
+73 -12
View File
@@ -1,31 +1,92 @@
import { execFileSync } from 'child_process'
import { execFile, execFileSync } from 'child_process'
// Cached pwsh availability check — evaluated once per process lifetime
let pwshAvailableCache: boolean | null = null
const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000
const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000
const PWSH_NEGATIVE_CACHE_TTL_MS = 30_000
type PwshAvailabilityCache =
| { available: true }
| { available: false; cachedAt: number; retryable: boolean }
let pwshAvailableCache: PwshAvailabilityCache | null = null
let pwshWarmupInFlight: Promise<boolean> | null = null
function isCacheFresh(cache: PwshAvailabilityCache): boolean {
return (
cache.available || !cache.retryable || Date.now() - cache.cachedAt < PWSH_NEGATIVE_CACHE_TTL_MS
)
}
function isTimeoutError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
)
}
function cachePwshProbeFailure(error: unknown): void {
// Why: pwsh.exe cold starts can exceed the sync timeout; do not let one slow
// .NET startup disable the user's PowerShell 7 preference for the daemon.
if (isTimeoutError(error)) {
pwshAvailableCache = null
return
}
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: true }
}
/**
* Check whether pwsh.exe is available on this Windows machine.
* Result is cached for the process lifetime.
* Positive results are cached for the process lifetime; negative results are
* retried so transient cold-start failures cannot outlive the daemon.
*/
export function isPwshAvailable(): boolean {
if (pwshAvailableCache !== null) {
return pwshAvailableCache
if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) {
return pwshAvailableCache.available
}
if (process.platform !== 'win32') {
pwshAvailableCache = false
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
return false
}
try {
execFileSync('pwsh.exe', ['-Version'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
timeout: PWSH_SYNC_PROBE_TIMEOUT_MS
})
pwshAvailableCache = true
} catch {
pwshAvailableCache = false
pwshAvailableCache = { available: true }
} catch (error) {
cachePwshProbeFailure(error)
}
return pwshAvailableCache
return pwshAvailableCache?.available ?? false
}
export function warmPwshAvailabilityCache(): Promise<boolean> {
if (pwshAvailableCache?.available) {
return Promise.resolve(true)
}
if (process.platform !== 'win32') {
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
return Promise.resolve(false)
}
if (pwshWarmupInFlight) {
return pwshWarmupInFlight
}
pwshWarmupInFlight = new Promise((resolve) => {
execFile('pwsh.exe', ['-Version'], { timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS }, (error) => {
pwshWarmupInFlight = null
if (!error) {
pwshAvailableCache = { available: true }
resolve(true)
return
}
cachePwshProbeFailure(error)
resolve(false)
})
})
return pwshWarmupInFlight
}
+5 -1
View File
@@ -83,7 +83,11 @@ async function markRemoteCodexProjectTrusted(
const codexDir = `${remoteHome}/.codex`
const configPath = `${codexDir}/config.toml`
const existing = await readRemoteTextFile(fsProvider, configPath)
const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted')
const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted', {
// Why: workspacePath was resolved by the remote filesystem provider; local
// realpath would canonicalize the wrong machine on SSH.
alreadyCanonical: true
})
if (updated === existing) {
return
}

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