feat(cli): add local background sessions (#1642)

* feat(cli): add local background sessions

Add local detached background sessions backed by an OpenClaude-owned registry under the resolved config directory.

- implement --bg spawning plus ps, logs, logs -f, kill, and an explicit attach limitation
- harden registry metadata validation, atomic writes, ID/name collision handling, and terminal-name reuse
- precreate child log files with precise ownership cleanup and register metadata only after spawn succeeds
- verify live PIDs against the session command before treating registry entries as running
- wait for process-tree termination and escalate to SIGKILL before marking sessions killed
- skip live local background sessions during --continue transcript selection
- preserve Node heap flags for detached children while avoiding stale launcher relaunch state
- handle -- separators so dash-prefixed prompts remain positional
- document storage, safety model, name reuse, and the current attach limitation

Validation:
- bun test
- bun run typecheck
- bun run smoke
- isolated built-CLI --bg/ps/logs/kill smoke
- CodeRabbit review findings addressed

* test(utils): prevent bg registry mock leakage

Restore complete bg registry and UDS module mocks after conversation recovery tests so Bun's process-global mock.module registry cannot leak partial module exports into later CLI tests.

CI exposed this under Bun 1.3.13 when conversationRecovery.test ran before the bgRegistry and bg CLI test files.

* test(utils): exercise bg registry without global mock

Replace the conversation recovery bgRegistry module mock with real registry metadata backed by a short-lived live child process. This keeps UDS as the only mocked boundary and avoids leaking a mocked registry module into later CLI registry tests under Bun 1.3.13.

* test(utils): isolate background registry state

Stop the conversation recovery test from using process-wide bgRegistry mocks or real child processes by injecting the live-session dependencies directly.

Pin and serialize the bg registry test config directory through the shared env mutation lock so path/cache state cannot leak from neighboring tests under Bun CI ordering.

* test(utils): document Bun mock restoration

Explain why conversation recovery tests re-register full module exports after mock.restore(), matching the CodeRabbit-requested Bun 1.3.13 isolation workaround.

* test(cli): isolate background registry root

Avoid relying on process-wide CLAUDE_CONFIG_DIR state in bgRegistry tests. Use a registry-local test root override so CI file ordering and mocked path modules cannot redirect background session metadata into another test's temp directory.

* test(utils): cover live session fallback paths

Add focused coverage for collectLiveBackgroundSessionIds when UDS discovery fails but registry data remains available, and when registry refresh fails but UDS data remains available.

* fix(cli): harden background session management

Validate persisted and newly-created background session PIDs before exposing them to management commands.

Reserve named live sessions with an atomic registry write, release reservations when sessions become terminal, and cover concurrent duplicate-name attempts.

Split local session management dispatch from background spawning so ps/logs/attach/kill avoid provider startup while --bg still inherits profile routing.

* fix(cli): address background session review findings

Preserve positional prompts when --bg is combined with optional-value flags such as --debug.

Recover stale name reservations whose owner metadata is missing or terminal while preserving in-flight reservations from live creators.

Cover both reviewer findings with focused parser and registry regression tests.

* fix(cli): respect delimiter for background flags

Limit background and print-mode flag detection to arguments before the -- delimiter so flag-shaped prompts remain positional.

Keep optional resume/from-pr flags out of the required-value table and add regressions for delimiter and optional-flag prompt handling.

* refactor(cli): share delimiter argument helper

Move args-before-delimiter handling into the existing dependency-free CLI args utility.

Use a dynamic import from the entrypoint so background flag routing shares the helper without adding top-level module load to version and management fast paths.

* test(cli): cover background entrypoint routing

Export the CLI entrypoint for controlled tests and add isolated importer injection so runtime routing tests do not leak global module mocks.

Replace the delimiter source-layout assertion with execution-level coverage for management commands, real background flags, and flag-shaped prompt text after --.

* fix(cli): preserve background resume selectors

Keep space-separated --resume, -r, and --from-pr values attached when building background child args.

Mark live background sessions stale when PID command identity cannot be read, avoiding termination of reused unrelated PIDs.

* fix(cli): track unknown background session identity

Represent unreadable live PID identity as a non-terminal unknown state so active sessions stay excluded from resume selection.

Refuse to terminate unknown live PIDs because the process command cannot be positively matched to the background session.

* fix(cli): honor background resume selectors

Avoid adding a generated --session-id to non-forked background resume launches so the spawned print-mode child satisfies the existing resume/session-id contract.

Pass --from-pr through headless print mode and resolve PR-linked sessions through the shared conversation recovery path.

Add regression coverage for background resume launch args and PR selector matching.

* fix(cli): treat PR resume as headless resume source

Include --from-pr in print-mode resume guards so PR-linked headless resumes can run without a prompt and share resume-only options.

Skip eager startup hooks for headless PR resumes and add explicit --session-id launch coverage.

* fix(cli): keep background PR resumes live

Resolve non-forked --from-pr background launches to the selected transcript id before writing registry metadata.

Preserve PID identity refresh for PR-resume children by matching the stored invocation when argv does not carry the transcript id.

Add regressions for launch registration and registry refresh.

* test(cli): cover PR resume lookup failures

Add regression coverage for non-forked background --from-pr launches when the selector cannot be resolved.

Verify the launch planner returns the same clear error used by handleBgFlag().
This commit is contained in:
Bogdan
2026-06-17 11:09:23 +08:00
committed by GitHub
parent 1aabe261db
commit a1b3346f65
13 changed files with 3022 additions and 160 deletions
+25
View File
@@ -105,6 +105,31 @@ Inside OpenClaude:
- run `/provider` for guided provider setup and saved profiles
- run `/onboard-github` for GitHub Models onboarding
### Background sessions
Run long non-interactive prompts detached from the current terminal:
```bash
openclaude --bg "fix failing tests"
openclaude --bg --name auth-refactor "refactor auth middleware"
openclaude ps
openclaude logs auth-refactor
openclaude logs auth-refactor -f
openclaude kill auth-refactor
```
Background sessions are local child processes. OpenClaude does not start a daemon
or network service, and permission/provider/model/settings flags are passed to
the child process the same way they are for a foreground `--print` run. Session
metadata and logs are stored under the resolved OpenClaude config directory,
usually `~/.openclaude/bg-sessions/`; `CLAUDE_CONFIG_DIR` can point OpenClaude
somewhere else. Session names can be reused after older sessions reach a
terminal state; use the session ID to inspect older logs with the same name.
`openclaude attach <id-or-name>` currently reports the matching session and
points to `openclaude logs <id> -f`; full terminal reattach is not implemented
for local background sessions yet.
### Fastest OpenAI setup
macOS / Linux:
+2 -12
View File
@@ -31,7 +31,7 @@ const featureFlags: Record<string, boolean> = {
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
HISTORY_SNIP: true, // Model-callable snip tool for context management
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
BG_SESSIONS: true, // Local detached background sessions
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
CHICAGO_MCP: false, // Computer-use MCP (native Swift modules stubbed)
COWORKER_TYPE_TELEMETRY: false, // Telemetry for agent/coworker type classification
@@ -153,16 +153,6 @@ result = await Bun.build({
'../daemon/main.js',
'export async function daemonMain() { throw new Error("Daemon mode is unavailable in the open build."); }',
],
[
'../cli/bg.js',
`
export async function psHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function logsHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function attachHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function killHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function handleBgFlag() { throw new Error("Background sessions are unavailable in the open build."); }
`,
],
[
'../cli/handlers/templateJobs.js',
'export async function templatesMain() { throw new Error("Template jobs are unavailable in the open build."); }',
@@ -183,7 +173,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are
// before the JS plugin phase runs.
build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
args => {
if (!internalFeatureStubModules.has(args.path)) return null
return {
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it } from 'bun:test'
import {
buildBackgroundSessionLaunch,
buildBackgroundChildProcessConfig,
terminateBackgroundProcessTree,
parseBackgroundInvocation,
parseLogsInvocation,
} from './bg.js'
describe('background session CLI parsing', () => {
it('builds a print-mode child command and preserves provider/model flags', () => {
const parsed = parseBackgroundInvocation([
'--provider',
'openai',
'--model',
'gpt-5',
'--bg',
'--name',
'auth-refactor',
'refactor auth middleware',
])
expect(parsed.name).toBe('auth-refactor')
expect(parsed.prompt).toBe('refactor auth middleware')
expect(parsed.childArgs).toEqual([
'--provider',
'openai',
'--model',
'gpt-5',
'--name',
'auth-refactor',
'--print',
'refactor auth middleware',
])
})
it('does not duplicate --print when the user already passed it', () => {
const parsed = parseBackgroundInvocation([
'--background',
'--print',
'--max-turns',
'2',
'fix failing tests',
])
expect(parsed.childArgs).toEqual([
'--print',
'--max-turns',
'2',
'fix failing tests',
])
})
it('preserves the prompt when --debug has no inline filter', () => {
const parsed = parseBackgroundInvocation([
'--bg',
'--debug',
'fix failing tests',
])
expect(parsed.prompt).toBe('fix failing tests')
expect(parsed.childArgs).toEqual(['--debug', '--print', 'fix failing tests'])
})
it('preserves inline --debug filters while finding the prompt', () => {
const parsed = parseBackgroundInvocation([
'--bg',
'--debug=api,hooks',
'fix failing tests',
])
expect(parsed.prompt).toBe('fix failing tests')
expect(parsed.childArgs).toEqual([
'--debug=api,hooks',
'--print',
'fix failing tests',
])
})
it('preserves space-separated resume and PR option values', () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000'
const resumeParsed = parseBackgroundInvocation([
'--bg',
'--resume',
sessionId,
])
const fromPrParsed = parseBackgroundInvocation([
'--bg',
'--from-pr',
'1642',
])
const shortResumeParsed = parseBackgroundInvocation([
'--bg',
'-r',
sessionId,
])
const inlineResumeParsed = parseBackgroundInvocation([
'--bg',
'--resume=auth',
])
expect(resumeParsed.prompt).toBeUndefined()
expect(resumeParsed.childArgs).toEqual([
'--resume',
sessionId,
'--print',
])
expect(fromPrParsed.prompt).toBeUndefined()
expect(fromPrParsed.childArgs).toEqual([
'--from-pr',
'1642',
'--print',
])
expect(shortResumeParsed.prompt).toBeUndefined()
expect(shortResumeParsed.childArgs).toEqual(['-r', sessionId, '--print'])
expect(inlineResumeParsed.prompt).toBeUndefined()
expect(inlineResumeParsed.childArgs).toEqual(['--resume=auth', '--print'])
})
it('finds the prompt after a space-separated resume option value', () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000'
const parsed = parseBackgroundInvocation([
'--bg',
'--resume',
sessionId,
'continue the fix',
])
expect(parsed.prompt).toBe('continue the fix')
expect(parsed.childArgs).toEqual([
'--resume',
sessionId,
'--print',
'continue the fix',
])
})
it('does not inject a generated session id when resuming without forking', async () => {
const resumeSessionId = '550e8400-e29b-41d4-a716-446655440000'
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
const launch = await buildBackgroundSessionLaunch(
['--resume', resumeSessionId, '--print'],
generatedSessionId,
)
expect(launch.sessionId).toBe(resumeSessionId)
expect(launch.childArgs).toEqual(['--resume', resumeSessionId, '--print'])
})
it('preserves an explicit session id without injecting a generated one', async () => {
const explicitSessionId = '550e8400-e29b-41d4-a716-446655440000'
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
const launch = await buildBackgroundSessionLaunch(
['--session-id', explicitSessionId, '--print', 'fix failing tests'],
generatedSessionId,
)
expect(launch.sessionId).toBe(explicitSessionId)
expect(launch.childArgs).toEqual([
'--session-id',
explicitSessionId,
'--print',
'fix failing tests',
])
})
it('uses a generated session id for forked background resumes', async () => {
const resumeSessionId = '550e8400-e29b-41d4-a716-446655440000'
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
const launch = await buildBackgroundSessionLaunch(
['--resume', resumeSessionId, '--fork-session', '--print'],
generatedSessionId,
)
expect(launch.sessionId).toBe(generatedSessionId)
expect(launch.childArgs).toEqual([
'--resume',
resumeSessionId,
'--fork-session',
'--print',
'--session-id',
generatedSessionId,
])
})
it('registers non-forked PR resumes under the selected transcript id', async () => {
const generatedSessionId = '00000000-0000-4000-8000-000000000001'
const prSessionId = '550e8400-e29b-41d4-a716-446655440000'
const seenSelectors: unknown[] = []
const launch = await buildBackgroundSessionLaunch(
['--from-pr', '1642', '--print'],
generatedSessionId,
{
resolvePrResumeSessionId: async selector => {
seenSelectors.push(selector)
return prSessionId
},
},
)
expect(seenSelectors).toEqual(['1642'])
expect(launch.sessionId).toBe(prSessionId)
expect(launch.childArgs).toEqual(['--from-pr', '1642', '--print'])
})
it('fails when a non-forked PR resume selector cannot be resolved', async () => {
await expect(
buildBackgroundSessionLaunch(
['--from-pr', '1642', '--print'],
'00000000-0000-4000-8000-000000000001',
{
resolvePrResumeSessionId: async () => null,
},
),
).rejects.toThrow('No conversation found linked to PR selector: 1642')
})
it('inserts generated flags before -- so dash-prefixed prompts stay positional', () => {
const parsed = parseBackgroundInvocation(['--bg', '--', '--fix-tests'])
expect(parsed.prompt).toBe('--fix-tests')
expect(parsed.childArgs).toEqual(['--print', '--', '--fix-tests'])
})
it('injects print mode when the prompt after -- looks like a print flag', () => {
const longFlagParsed = parseBackgroundInvocation(['--bg', '--', '--print'])
const shortFlagParsed = parseBackgroundInvocation(['--bg', '--', '-p'])
expect(longFlagParsed.prompt).toBe('--print')
expect(longFlagParsed.childArgs).toEqual(['--print', '--', '--print'])
expect(shortFlagParsed.prompt).toBe('-p')
expect(shortFlagParsed.childArgs).toEqual(['--print', '--', '-p'])
})
it('does not strip --bg when it appears after -- as the prompt', () => {
const parsed = parseBackgroundInvocation(['--bg', '--', '--bg'])
expect(parsed.prompt).toBe('--bg')
expect(parsed.childArgs).toEqual(['--print', '--', '--bg'])
})
it('parses log follow mode', () => {
expect(parseLogsInvocation(['auth-refactor', '-f'])).toEqual({
target: 'auth-refactor',
follow: true,
stream: 'stdout',
})
expect(parseLogsInvocation(['auth-refactor', '--stderr'])).toEqual({
target: 'auth-refactor',
follow: false,
stream: 'stderr',
})
})
it('preserves Node exec flags and lets the launcher manage heap relaunch state', () => {
const config = buildBackgroundChildProcessConfig({
execPath: '/usr/bin/node',
execArgv: ['--max-old-space-size=8192', '--expose-gc'],
entrypoint: '/repo/bin/openclaude',
childArgs: ['--print', 'fix failing tests'],
processEnv: {
OPENCLAUDE_HEAP_RELAUNCHED: '1',
OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '8192',
},
sessionName: 'tests',
stdoutLogPath: '/tmp/bg.out.log',
})
expect(config.command).toBe('/usr/bin/node')
expect(config.args).toEqual([
'--max-old-space-size=8192',
'--expose-gc',
'/repo/bin/openclaude',
'--print',
'fix failing tests',
])
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBeUndefined()
expect(config.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB).toBe('8192')
expect(config.env.CLAUDE_CODE_SESSION_KIND).toBe('bg')
expect(config.env.CLAUDE_CODE_SESSION_LOG).toBe('/tmp/bg.out.log')
expect(config.env.CLAUDE_CODE_SESSION_NAME).toBe('tests')
})
it('escalates process-tree termination and waits for exit before returning', async () => {
const signals: Array<string | number | undefined> = []
let aliveChecks = 0
await terminateBackgroundProcessTree(123, {
isProcessAlive: () => {
aliveChecks++
return aliveChecks < 4
},
killTree: async (_pid, signal) => {
signals.push(signal)
},
sleep: async () => {},
termGraceMs: 1,
killGraceMs: 1,
pollIntervalMs: 1,
})
expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
})
})
+718 -22
View File
@@ -1,27 +1,723 @@
/**
* Inert stub for background-session management
* (`claude ps|logs|attach|kill` and `--bg`/`--background`).
*
* The bundler noop-stubs this specifier in current builds; this module
* mirrors that behavior for the typechecker. Every handler resolves
* immediately without touching the ~/.claude/sessions/ registry, so the
* commands exit quietly — the same observable behavior as the `() => null`
* bundler stubs. The call site in entrypoints/cli.tsx does not catch errors
* (`void main()`), so no-ops are preferred over throwing. No import-time
* side effects.
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { closeSync, openSync } from 'node:fs'
import { open, readFile, unlink } from 'node:fs/promises'
import { basename } from 'node:path'
import treeKill from 'tree-kill'
import { argsBeforeDelimiter } from '../utils/cliArgs.js'
import { isProcessRunning } from '../utils/genericProcessUtils.js'
import {
assertBackgroundSessionNameAvailable,
backgroundSessionLogExists,
createBackgroundSession,
ensureBackgroundSessionDirs,
getBackgroundSessionLogPaths,
listBackgroundSessions,
markBackgroundSessionKilled,
refreshBackgroundSessionStatuses,
resolveBackgroundSession,
} from './bgRegistry.js'
/** `claude ps [...]` — list background sessions. Stub: no registry, no output. */
export async function psHandler(_args: string[]): Promise<void> {}
export type ParsedBackgroundInvocation = {
name?: string
prompt?: string
childArgs: string[]
}
/** `claude logs <id>` — tail a session log. Stub: no-op. */
export async function logsHandler(_sessionId: string | undefined): Promise<void> {}
export type ParsedLogsInvocation = {
target?: string
follow: boolean
stream: 'stdout' | 'stderr'
}
/** `claude attach <id>` — attach to a session. Stub: no-op. */
export async function attachHandler(_sessionId: string | undefined): Promise<void> {}
export type BackgroundChildProcessConfig = {
command: string
args: string[]
env: NodeJS.ProcessEnv
}
/** `claude kill <id>` — terminate a session. Stub: no-op. */
export async function killHandler(_sessionId: string | undefined): Promise<void> {}
export type BuildBackgroundChildProcessConfigInput = {
execPath: string
execArgv: string[]
entrypoint: string
childArgs: string[]
processEnv: NodeJS.ProcessEnv
sessionName?: string
stdoutLogPath: string
}
/** `claude --bg/--background ...` — spawn a detached session. Stub: no-op. */
export async function handleBgFlag(_args: string[]): Promise<void> {}
type PrResumeSelector = true | string
export type BuildBackgroundSessionLaunchDeps = {
resolvePrResumeSessionId?: (
selector: PrResumeSelector,
) => Promise<string | null | undefined>
}
const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED'
const DEFAULT_TERM_GRACE_MS = 2_000
const DEFAULT_KILL_GRACE_MS = 2_000
const DEFAULT_KILL_POLL_INTERVAL_MS = 100
// This must stay in sync with value-consuming CLI flags in main.tsx and related
// handlers. If the CLI flag definitions become centralized, move this parser
// metadata there instead of maintaining a second hand-written list.
const REQUIRED_OPTION_VALUE_FLAGS = new Set([
'--add-dir',
'--agent',
'--agents',
'--allowed-tools',
'--allowedTools',
'--append-system-prompt',
'--append-system-prompt-file',
'--betas',
'--debug-file',
'--disallowed-tools',
'--disallowedTools',
'--effort',
'--fallback-model',
'--file',
'--input-format',
'--json-schema',
'--max-budget-usd',
'--max-turns',
'--mcp-config',
'--model',
'--name',
'--output-format',
'--permission-mode',
'--permission-prompt-tool',
'--plugin-dir',
'--prefill',
'--provider',
'--resume-session-at',
'--rewind-files',
'--session-id',
'--setting-sources',
'--settings',
'--system-prompt',
'--system-prompt-file',
'--task-budget',
'--tools',
'--workload',
'-n',
])
const INLINE_OPTIONAL_VALUE_FLAGS = new Set([
'--debug',
'-d',
])
const SPACE_OPTIONAL_VALUE_FLAGS = new Set([
'--from-pr',
'--resume',
'-r',
])
function safeNodeExecArgvForBackground(execArgv: string[]): string[] {
return execArgv.filter(
arg =>
arg === '--expose-gc' ||
arg.startsWith('--max-old-space-size') ||
arg.startsWith('--heapsnapshot-near-heap-limit'),
)
}
export function buildBackgroundChildProcessConfig(
input: BuildBackgroundChildProcessConfigInput,
): BackgroundChildProcessConfig {
const env: NodeJS.ProcessEnv = {
...input.processEnv,
CLAUDE_CODE_ENTRYPOINT: 'bg',
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_LOG: input.stdoutLogPath,
...(input.sessionName
? { CLAUDE_CODE_SESSION_NAME: input.sessionName }
: {}),
}
delete env[HEAP_RELAUNCHED_ENV]
return {
command: input.execPath,
args: [
...safeNodeExecArgvForBackground(input.execArgv),
input.entrypoint,
...input.childArgs,
],
env,
}
}
function fail(message: string): never {
console.error(`Error: ${message}`)
process.exit(1)
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
async function resolveSessionOrExit(target: string) {
try {
return await resolveBackgroundSession(target)
} catch (error) {
fail(errorMessage(error))
}
}
function stripBackgroundFlag(args: string[]): string[] {
const delimiterIndex = args.indexOf('--')
const head = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex)
const tail = delimiterIndex === -1 ? [] : args.slice(delimiterIndex)
return [
...head.filter(arg => arg !== '--bg' && arg !== '--background'),
...tail,
]
}
function findPromptIndex(args: string[]): number {
const dashDash = args.indexOf('--')
if (dashDash !== -1) {
return dashDash + 1 < args.length ? dashDash + 1 : -1
}
const consumedValues = new Set<number>()
for (let i = 0; i < args.length - 1; i++) {
const arg = args[i]
if (REQUIRED_OPTION_VALUE_FLAGS.has(arg)) {
consumedValues.add(i + 1)
i++
continue
}
if (SPACE_OPTIONAL_VALUE_FLAGS.has(arg)) {
const next = args[i + 1]
if (next && !next.startsWith('-')) {
consumedValues.add(i + 1)
i++
}
continue
}
if (INLINE_OPTIONAL_VALUE_FLAGS.has(arg)) {
// Keep debug filters inline-only here so `--debug "prompt"` remains a
// background prompt instead of being consumed as a logging filter.
continue
}
}
for (let i = args.length - 1; i >= 0; i--) {
if (consumedValues.has(i)) continue
const arg = args[i]
if (arg === '--') continue
if (!arg.startsWith('-')) return i
}
return -1
}
function findFlagValue(args: string[], flag: string): string | undefined {
const inlinePrefix = `${flag}=`
const searchable = argsBeforeDelimiter(args)
for (let i = 0; i < searchable.length; i++) {
const arg = searchable[i]
if (arg.startsWith(inlinePrefix)) return arg.slice(inlinePrefix.length)
if (
arg === flag &&
i + 1 < searchable.length &&
!searchable[i + 1]?.startsWith('-')
) {
return searchable[i + 1]
}
}
return undefined
}
function findSessionName(args: string[]): string | undefined {
return findFlagValue(args, '--name') ?? findFlagValue(args, '-n')
}
function hasPrintMode(args: string[]): boolean {
const searchable = argsBeforeDelimiter(args)
return searchable.includes('--print') || searchable.includes('-p')
}
function insertBeforePrompt(args: string[], values: string[]): string[] {
const next = [...args]
const delimiterIndex = next.indexOf('--')
const insertionIndex =
delimiterIndex === -1
? findPromptIndex(next)
: delimiterIndex
next.splice(insertionIndex === -1 ? next.length : insertionIndex, 0, ...values)
return next
}
function withGeneratedSessionId(args: string[], sessionId: string): string[] {
if (findFlagValue(args, '--session-id')) return args
return insertBeforePrompt(args, ['--session-id', sessionId])
}
function hasForkSession(args: string[]): boolean {
return argsBeforeDelimiter(args).includes('--fork-session')
}
function findFromPrSelector(args: string[]): PrResumeSelector | undefined {
const searchable = argsBeforeDelimiter(args)
const inlinePrefix = '--from-pr='
for (let i = 0; i < searchable.length; i++) {
const arg = searchable[i]
if (arg.startsWith(inlinePrefix)) {
return arg.slice(inlinePrefix.length) || true
}
if (arg === '--from-pr') {
const next = searchable[i + 1]
return next && !next.startsWith('-') ? next : true
}
}
return undefined
}
function hasResumeSource(args: string[]): boolean {
return Boolean(
findFlagValue(args, '--resume') ??
findFlagValue(args, '-r') ??
findFromPrSelector(args),
)
}
async function resolvePrResumeSessionId(
selector: PrResumeSelector,
deps: BuildBackgroundSessionLaunchDeps,
): Promise<string | null | undefined> {
if (deps.resolvePrResumeSessionId) {
return deps.resolvePrResumeSessionId(selector)
}
const { findResumeSessionIdByPrSelector } = await import(
'../utils/conversationRecovery.js'
)
return findResumeSessionIdByPrSelector(selector)
}
export async function buildBackgroundSessionLaunch(
childArgs: string[],
generatedSessionId: string,
deps: BuildBackgroundSessionLaunchDeps = {},
): Promise<{ childArgs: string[]; sessionId: string }> {
const explicitSessionId = findFlagValue(childArgs, '--session-id')
if (explicitSessionId) {
return { childArgs, sessionId: explicitSessionId }
}
const resumeSessionId =
findFlagValue(childArgs, '--resume') ?? findFlagValue(childArgs, '-r')
if (resumeSessionId && !hasForkSession(childArgs)) {
return { childArgs, sessionId: resumeSessionId }
}
const fromPrSelector = findFromPrSelector(childArgs)
if (fromPrSelector !== undefined && !hasForkSession(childArgs)) {
const sessionId = await resolvePrResumeSessionId(fromPrSelector, deps)
if (!sessionId) {
const description =
fromPrSelector === true ? 'any PR' : `PR selector: ${fromPrSelector}`
throw new Error(`No conversation found linked to ${description}`)
}
return { childArgs, sessionId }
}
return {
childArgs: withGeneratedSessionId(childArgs, generatedSessionId),
sessionId: generatedSessionId,
}
}
export function parseBackgroundInvocation(
args: string[],
): ParsedBackgroundInvocation {
let childArgs = stripBackgroundFlag(args)
const name = findSessionName(childArgs)?.trim() || undefined
const promptIndex = findPromptIndex(childArgs)
const prompt = promptIndex === -1 ? undefined : childArgs[promptIndex]
if (!hasPrintMode(childArgs)) {
childArgs = insertBeforePrompt(childArgs, ['--print'])
}
return {
...(name ? { name } : {}),
...(prompt ? { prompt } : {}),
childArgs,
}
}
export function parseLogsInvocation(args: string[]): ParsedLogsInvocation {
let follow = false
let stream: ParsedLogsInvocation['stream'] = 'stdout'
let target: string | undefined
for (const arg of args) {
if (arg === '-f' || arg === '--follow') {
follow = true
continue
}
if (arg === '--stderr') {
stream = 'stderr'
continue
}
if (arg === '--stdout') {
stream = 'stdout'
continue
}
target ??= arg
}
return { target, follow, stream }
}
function backgroundSessionId(): string {
return `bg-${randomUUID().slice(0, 8)}`
}
function formatCommand(command: string[]): string {
return command
.map(part => (/\s/.test(part) ? JSON.stringify(part) : part))
.join(' ')
}
function printSessionTable(
sessions: Awaited<ReturnType<typeof listBackgroundSessions>>,
): void {
if (sessions.length === 0) {
console.log('No background sessions.')
return
}
const rows = [
['ID', 'STATUS', 'PID', 'NAME', 'STARTED', 'CWD'],
...sessions.map(session => [
session.id,
session.status,
String(session.pid),
session.name ?? '-',
session.startedAt,
session.cwd,
]),
]
const widths = rows[0].map((_, col) =>
Math.max(...rows.map(row => row[col].length)),
)
for (const row of rows) {
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '))
}
}
async function printExistingLog(path: string): Promise<number> {
try {
const contents = await readFile(path)
if (contents.length > 0) process.stdout.write(contents)
return contents.length
} catch {
return 0
}
}
async function followLogFile(path: string, offset: number): Promise<void> {
let position = offset
let reading = false
await new Promise<void>(resolve => {
const cleanup = () => {
clearInterval(timer)
process.off('SIGINT', cleanup)
process.off('SIGTERM', cleanup)
resolve()
}
const timer = setInterval(() => {
if (reading) return
reading = true
void (async () => {
try {
const handle = await open(path, 'r')
try {
const { size } = await handle.stat()
if (size < position) position = 0
if (size > position) {
const buffer = Buffer.alloc(size - position)
await handle.read(buffer, 0, buffer.length, position)
position = size
process.stdout.write(buffer)
}
} finally {
await handle.close()
}
} catch {
// Keep following; the child may create or rotate the file later.
} finally {
reading = false
}
})()
}, 500)
process.once('SIGINT', cleanup)
process.once('SIGTERM', cleanup)
})
}
function normalizeArgs(args: string[] | string | undefined): string[] {
if (Array.isArray(args)) return args
return args ? [args] : []
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function treeKillAsync(pid: number, signal: string | number): Promise<void> {
await new Promise<void>((resolve, reject) => {
treeKill(pid, signal, error => {
if (error && isProcessRunning(pid)) {
reject(error)
return
}
// The process may exit naturally after the liveness check but before
// tree-kill reaches it; that race is already the requested outcome.
resolve()
})
})
}
async function waitForProcessExit(
pid: number,
options: {
isProcessAlive: (pid: number) => boolean
sleep: (ms: number) => Promise<void>
graceMs: number
pollIntervalMs: number
},
): Promise<boolean> {
const attempts = Math.max(
1,
Math.ceil(options.graceMs / options.pollIntervalMs),
)
for (let i = 0; i < attempts; i++) {
if (!options.isProcessAlive(pid)) return true
await options.sleep(options.pollIntervalMs)
}
return !options.isProcessAlive(pid)
}
export async function terminateBackgroundProcessTree(
pid: number,
options?: {
isProcessAlive?: (pid: number) => boolean
killTree?: (pid: number, signal: string | number) => Promise<void>
sleep?: (ms: number) => Promise<void>
termGraceMs?: number
killGraceMs?: number
pollIntervalMs?: number
},
): Promise<void> {
const isProcessAlive = options?.isProcessAlive ?? isProcessRunning
const killTree = options?.killTree ?? treeKillAsync
const sleepFn = options?.sleep ?? sleep
const pollIntervalMs =
options?.pollIntervalMs ?? DEFAULT_KILL_POLL_INTERVAL_MS
if (!isProcessAlive(pid)) return
await killTree(pid, 'SIGTERM')
if (
await waitForProcessExit(pid, {
isProcessAlive,
sleep: sleepFn,
graceMs: options?.termGraceMs ?? DEFAULT_TERM_GRACE_MS,
pollIntervalMs,
})
) {
return
}
await killTree(pid, 'SIGKILL')
if (
await waitForProcessExit(pid, {
isProcessAlive,
sleep: sleepFn,
graceMs: options?.killGraceMs ?? DEFAULT_KILL_GRACE_MS,
pollIntervalMs,
})
) {
return
}
throw new Error(`Process ${pid} did not exit after SIGKILL`)
}
export async function psHandler(_args: string[]): Promise<void> {
const sessions = await refreshBackgroundSessionStatuses()
printSessionTable(sessions)
}
export async function logsHandler(
args: string[] | string | undefined,
): Promise<void> {
const parsed = parseLogsInvocation(normalizeArgs(args))
if (!parsed.target) fail('Usage: openclaude logs <id-or-name> [-f]')
await refreshBackgroundSessionStatuses()
const session = await resolveSessionOrExit(parsed.target)
const logPath =
parsed.stream === 'stderr' ? session.stderrLogPath : session.stdoutLogPath
if (!(await backgroundSessionLogExists(logPath))) {
fail(`Log file does not exist: ${logPath}`)
}
const offset = await printExistingLog(logPath)
if (parsed.follow) {
await followLogFile(logPath, offset)
}
}
export async function attachHandler(
args: string[] | string | undefined,
): Promise<void> {
const target = normalizeArgs(args)[0]
if (!target) fail('Usage: openclaude attach <id-or-name>')
await refreshBackgroundSessionStatuses()
const session = await resolveSessionOrExit(target)
console.error(
`Attach is not implemented for local background sessions yet. Use \`openclaude logs ${session.id} -f\` to follow output.`,
)
process.exitCode = 1
}
export async function killHandler(
args: string[] | string | undefined,
): Promise<void> {
const target = normalizeArgs(args)[0]
if (!target) fail('Usage: openclaude kill <id-or-name>')
await refreshBackgroundSessionStatuses()
const session = await resolveSessionOrExit(target)
if (session.status === 'unknown' && isProcessRunning(session.pid)) {
fail(
`Cannot safely kill background session ${session.id}: process identity could not be verified`,
)
}
if (session.status === 'running' && isProcessRunning(session.pid)) {
await terminateBackgroundProcessTree(session.pid).catch(error => {
fail(
`Failed to kill background session ${session.id}: ${errorMessage(error)}`,
)
})
}
const killed = await markBackgroundSessionKilled(session.id)
console.log(`Killed background session ${killed.id}.`)
}
export async function handleBgFlag(args: string[]): Promise<void> {
const parsed = parseBackgroundInvocation(args)
if (!parsed.prompt && !hasResumeSource(parsed.childArgs)) {
fail('Usage: openclaude --bg [--name <name>] "<prompt>"')
}
try {
await assertBackgroundSessionNameAvailable(parsed.name)
} catch (error) {
fail(errorMessage(error))
}
const id = backgroundSessionId()
const { childArgs, sessionId } = await buildBackgroundSessionLaunch(
parsed.childArgs,
randomUUID(),
).catch(error => {
fail(errorMessage(error))
})
const logPaths = getBackgroundSessionLogPaths(id)
await ensureBackgroundSessionDirs()
const entrypoint = process.argv[1]
if (!entrypoint) {
fail('Cannot determine OpenClaude entrypoint for background session')
}
const childConfig = buildBackgroundChildProcessConfig({
execPath: process.execPath,
execArgv: process.execArgv,
entrypoint,
childArgs,
processEnv: process.env,
sessionName: parsed.name,
stdoutLogPath: logPaths.stdoutLogPath,
})
let stdoutFd: number | undefined
let stderrFd: number | undefined
let createdStdoutLog = false
let createdStderrLog = false
const cleanupCreatedLogs = async () => {
if (createdStdoutLog) await unlink(logPaths.stdoutLogPath).catch(() => {})
if (createdStderrLog) await unlink(logPaths.stderrLogPath).catch(() => {})
}
let child
try {
stdoutFd = openSync(logPaths.stdoutLogPath, 'wx')
createdStdoutLog = true
stderrFd = openSync(logPaths.stderrLogPath, 'wx')
createdStderrLog = true
child = spawn(childConfig.command, childConfig.args, {
cwd: process.cwd(),
detached: true,
env: childConfig.env,
stdio: ['ignore', stdoutFd, stderrFd],
})
child.unref()
} catch (error) {
if (stdoutFd !== undefined) {
closeSync(stdoutFd)
stdoutFd = undefined
}
if (stderrFd !== undefined) {
closeSync(stderrFd)
stderrFd = undefined
}
await cleanupCreatedLogs()
fail(`Failed to start background session: ${errorMessage(error)}`)
} finally {
if (stdoutFd !== undefined) closeSync(stdoutFd)
if (stderrFd !== undefined) closeSync(stderrFd)
}
if (!child.pid) {
await cleanupCreatedLogs()
fail('Failed to start background session')
}
const command = [childConfig.command, ...childConfig.args]
const session = await createBackgroundSession({
id,
name: parsed.name,
pid: child.pid,
cwd: process.cwd(),
command,
provider: findFlagValue(childArgs, '--provider'),
model: findFlagValue(childArgs, '--model'),
sessionId,
stdoutLogPath: logPaths.stdoutLogPath,
stderrLogPath: logPaths.stderrLogPath,
logFilesPrecreated: true,
}).catch(async error => {
await terminateBackgroundProcessTree(child.pid!).catch(() => {})
await cleanupCreatedLogs()
fail(errorMessage(error))
})
console.log(`Started background session ${session.id}.`)
if (session.name) console.log(`Name: ${session.name}`)
console.log(`PID: ${session.pid}`)
console.log(`Logs: ${session.stdoutLogPath}`)
console.log(`Follow: openclaude logs ${session.id} -f`)
console.log(
`Command: ${formatCommand([basename(childConfig.command), ...childConfig.args])}`,
)
}
+677
View File
@@ -0,0 +1,677 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
_setBackgroundSessionsRootForTesting,
createBackgroundSession,
isTerminalBackgroundSession,
listBackgroundSessions,
markBackgroundSessionKilled,
refreshBackgroundSessionStatuses,
resolveBackgroundSession,
} from './bgRegistry.js'
describe('background session registry', () => {
let configDir: string
function nameReservationPath(name: string): string {
const digest = createHash('sha256').update(name).digest('hex')
return join(configDir, 'bg-sessions', 'names', `${digest}.json`)
}
async function writeNameReservation(
name: string,
reservation: {
id: string
creatorPid?: number
createdAt?: string
},
): Promise<void> {
await mkdir(join(configDir, 'bg-sessions', 'names'), { recursive: true })
await writeFile(
nameReservationPath(name),
JSON.stringify({ name, ...reservation }),
)
}
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'openclaude-bg-registry-'))
_setBackgroundSessionsRootForTesting(join(configDir, 'bg-sessions'))
})
afterEach(async () => {
_setBackgroundSessionsRootForTesting(undefined)
await rm(configDir, { force: true, recursive: true })
})
it('creates session metadata and log files under the OpenClaude config dir', async () => {
const session = await createBackgroundSession({
id: 'bg-test-1',
name: 'auth-refactor',
pid: 12345,
cwd: '/repo',
command: ['openclaude', '--print', 'refactor auth'],
provider: 'openai',
model: 'gpt-5',
sessionId: 'conversation-1',
now: new Date('2026-06-15T08:00:00.000Z'),
})
expect(session).toMatchObject({
id: 'bg-test-1',
name: 'auth-refactor',
pid: 12345,
cwd: '/repo',
status: 'running',
provider: 'openai',
model: 'gpt-5',
sessionId: 'conversation-1',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:00:00.000Z',
command: ['openclaude', '--print', 'refactor auth'],
})
expect(session.stdoutLogPath).toBe(
join(configDir, 'bg-sessions', 'logs', 'bg-test-1.out.log'),
)
expect(session.stderrLogPath).toBe(
join(configDir, 'bg-sessions', 'logs', 'bg-test-1.err.log'),
)
const sessions = await listBackgroundSessions()
expect(sessions.map(s => s.id)).toEqual(['bg-test-1'])
})
it('resolves sessions by id, id prefix, and name', async () => {
await createBackgroundSession({
id: 'bg-abcdef',
name: 'named-session',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'work'],
sessionId: 'conversation-1',
})
expect((await resolveBackgroundSession('bg-abcdef')).id).toBe('bg-abcdef')
expect((await resolveBackgroundSession('bg-abc')).id).toBe('bg-abcdef')
expect((await resolveBackgroundSession('named-session')).id).toBe(
'bg-abcdef',
)
})
it('rejects missing and ambiguous session targets', async () => {
await createBackgroundSession({
id: 'bg-prefix-one',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'one'],
sessionId: 'conversation-1',
})
await createBackgroundSession({
id: 'bg-prefix-two',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
})
await expect(resolveBackgroundSession('missing')).rejects.toThrow(
'No background session found',
)
await expect(resolveBackgroundSession('bg-prefix')).rejects.toThrow(
'ambiguous',
)
})
it('rejects duplicate names and reports ambiguous names', async () => {
await createBackgroundSession({
id: 'bg-one',
name: 'shared',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'one'],
sessionId: 'conversation-1',
})
await expect(
createBackgroundSession({
id: 'bg-two',
name: 'shared',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
}),
).rejects.toThrow('already exists')
})
it('rejects concurrent duplicate live names atomically', async () => {
const attempts = await Promise.allSettled([
createBackgroundSession({
id: 'bg-race-one',
name: 'shared-race',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'one'],
sessionId: 'conversation-1',
}),
createBackgroundSession({
id: 'bg-race-two',
name: 'shared-race',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
}),
])
const fulfilled = attempts.filter(result => result.status === 'fulfilled')
const rejected = attempts.find(result => result.status === 'rejected')
expect(fulfilled).toHaveLength(1)
expect(rejected?.status).toBe('rejected')
if (!rejected || rejected.status !== 'rejected') {
throw new Error('Expected one duplicate-name registration to fail')
}
expect(String(rejected.reason?.message ?? rejected.reason)).toContain(
'already exists',
)
expect(
(await listBackgroundSessions()).filter(
session => session.name === 'shared-race',
),
).toHaveLength(1)
})
it('does not steal an in-flight name reservation from a live creator', async () => {
await writeNameReservation('in-flight', {
id: 'bg-in-flight',
creatorPid: process.pid,
createdAt: '2026-06-15T08:00:00.000Z',
})
await expect(
createBackgroundSession({
id: 'bg-contender',
name: 'in-flight',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'contender'],
sessionId: 'conversation-contender',
}),
).rejects.toThrow('already exists')
expect(await listBackgroundSessions()).toEqual([])
})
it('recovers orphaned name reservations whose owner metadata is missing', async () => {
await writeNameReservation('orphaned', {
id: 'bg-missing-owner',
creatorPid: Number.MAX_SAFE_INTEGER,
createdAt: '2026-06-15T08:00:00.000Z',
})
const session = await createBackgroundSession({
id: 'bg-recovered',
name: 'orphaned',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'recovered'],
sessionId: 'conversation-recovered',
})
expect(session.name).toBe('orphaned')
expect((await listBackgroundSessions()).map(s => s.id)).toEqual([
'bg-recovered',
])
})
it('recovers name reservations owned by terminal sessions', async () => {
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(
join(configDir, 'bg-sessions', 'sessions', 'bg-terminal-owner.json'),
JSON.stringify({
id: 'bg-terminal-owner',
name: 'terminal-name',
pid: 111,
cwd: '/repo',
status: 'killed',
sessionId: 'conversation-terminal',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:05:00.000Z',
command: ['openclaude', '--print', 'old'],
stdoutLogPath: '/tmp/old-out.log',
stderrLogPath: '/tmp/old-err.log',
}),
)
await writeNameReservation('terminal-name', {
id: 'bg-terminal-owner',
creatorPid: process.pid,
createdAt: '2026-06-15T08:00:00.000Z',
})
const session = await createBackgroundSession({
id: 'bg-new-owner',
name: 'terminal-name',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'new'],
sessionId: 'conversation-new',
})
expect(session.name).toBe('terminal-name')
expect((await resolveBackgroundSession('terminal-name')).id).toBe(
'bg-new-owner',
)
})
it('allows terminal session names to be reused and resolves the active match', async () => {
await createBackgroundSession({
id: 'bg-old',
name: 'reuse-me',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'old'],
sessionId: 'conversation-old',
})
await markBackgroundSessionKilled('bg-old')
await createBackgroundSession({
id: 'bg-new',
name: 'reuse-me',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'new'],
sessionId: 'conversation-new',
})
expect((await resolveBackgroundSession('reuse-me')).id).toBe('bg-new')
})
it('does not overwrite existing metadata on id collision', async () => {
await createBackgroundSession({
id: 'bg-collision',
name: 'first',
pid: 111,
cwd: '/repo',
command: ['openclaude', '--print', 'one'],
sessionId: 'conversation-1',
})
await expect(
createBackgroundSession({
id: 'bg-collision',
name: 'second',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
}),
).rejects.toThrow('already exists')
expect((await resolveBackgroundSession('bg-collision')).name).toBe('first')
})
it('rejects non-positive pids at creation', async () => {
await expect(
createBackgroundSession({
id: 'bg-zero-pid',
pid: 0,
cwd: '/repo',
command: ['openclaude', '--print', 'zero'],
sessionId: 'conversation-zero',
}),
).rejects.toThrow('Invalid background session pid')
await expect(
createBackgroundSession({
id: 'bg-negative-pid',
pid: -1,
cwd: '/repo',
command: ['openclaude', '--print', 'negative'],
sessionId: 'conversation-negative',
}),
).rejects.toThrow('Invalid background session pid')
expect(await listBackgroundSessions()).toEqual([])
})
it('registers a session whose log files were created before spawn', async () => {
const stdoutLogPath = join(
configDir,
'bg-sessions',
'logs',
'bg-precreated.out.log',
)
const stderrLogPath = join(
configDir,
'bg-sessions',
'logs',
'bg-precreated.err.log',
)
await mkdir(join(configDir, 'bg-sessions', 'logs'), {
recursive: true,
})
await writeFile(stdoutLogPath, '')
await writeFile(stderrLogPath, '')
const session = await createBackgroundSession({
id: 'bg-precreated',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'work'],
sessionId: 'conversation-1',
stdoutLogPath,
stderrLogPath,
logFilesPrecreated: true,
})
expect(session.stdoutLogPath).toBe(stdoutLogPath)
expect(session.stderrLogPath).toBe(stderrLogPath)
expect((await resolveBackgroundSession('bg-precreated')).id).toBe(
'bg-precreated',
)
})
it('preserves caller-owned precreated logs when metadata registration fails', async () => {
const stdoutLogPath = join(
configDir,
'bg-sessions',
'logs',
'bg-precreated-collision.out.log',
)
const stderrLogPath = join(
configDir,
'bg-sessions',
'logs',
'bg-precreated-collision.err.log',
)
await mkdir(join(configDir, 'bg-sessions', 'logs'), {
recursive: true,
})
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(stdoutLogPath, 'stdout already belongs to caller')
await writeFile(stderrLogPath, 'stderr already belongs to caller')
await writeFile(
join(
configDir,
'bg-sessions',
'sessions',
'bg-precreated-collision.json',
),
JSON.stringify({
id: 'bg-precreated-collision',
pid: 111,
cwd: '/repo',
status: 'running',
sessionId: 'conversation-1',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:00:00.000Z',
command: ['openclaude', '--print', 'one'],
stdoutLogPath: '/tmp/existing-out.log',
stderrLogPath: '/tmp/existing-err.log',
}),
)
await expect(
createBackgroundSession({
id: 'bg-precreated-collision',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
stdoutLogPath,
stderrLogPath,
logFilesPrecreated: true,
}),
).rejects.toThrow('already exists')
expect(await Bun.file(stdoutLogPath).text()).toBe(
'stdout already belongs to caller',
)
expect(await Bun.file(stderrLogPath).text()).toBe(
'stderr already belongs to caller',
)
})
it('cleans up logs created before detecting a metadata id collision', async () => {
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(
join(configDir, 'bg-sessions', 'sessions', 'bg-log-cleanup.json'),
JSON.stringify({
id: 'bg-log-cleanup',
pid: 111,
cwd: '/repo',
status: 'running',
sessionId: 'conversation-1',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:00:00.000Z',
command: ['openclaude', '--print', 'one'],
stdoutLogPath: '/tmp/existing-out.log',
stderrLogPath: '/tmp/existing-err.log',
}),
)
await expect(
createBackgroundSession({
id: 'bg-log-cleanup',
pid: 222,
cwd: '/repo',
command: ['openclaude', '--print', 'two'],
sessionId: 'conversation-2',
}),
).rejects.toThrow('already exists')
expect(
await Bun.file(
join(configDir, 'bg-sessions', 'logs', 'bg-log-cleanup.out.log'),
).exists(),
).toBe(false)
expect(
await Bun.file(
join(configDir, 'bg-sessions', 'logs', 'bg-log-cleanup.err.log'),
).exists(),
).toBe(false)
})
it('marks running sessions stale when their process is gone', async () => {
await createBackgroundSession({
id: 'bg-stale',
pid: 333,
cwd: '/repo',
command: ['openclaude', '--print', 'work'],
sessionId: 'conversation-1',
now: new Date('2026-06-15T08:00:00.000Z'),
})
const refreshed = await refreshBackgroundSessionStatuses({
isProcessAlive: () => false,
now: new Date('2026-06-15T08:05:00.000Z'),
})
expect(refreshed).toHaveLength(1)
expect(refreshed[0]).toMatchObject({
id: 'bg-stale',
status: 'stale',
updatedAt: '2026-06-15T08:05:00.000Z',
})
})
it('keeps running sessions fresh when their process identity still matches', async () => {
await createBackgroundSession({
id: 'bg-running',
pid: 333,
cwd: '/repo',
command: ['openclaude', '--session-id', 'conversation-1', '--print', 'work'],
sessionId: 'conversation-1',
now: new Date('2026-06-15T08:00:00.000Z'),
})
const refreshed = await refreshBackgroundSessionStatuses({
isProcessAlive: () => true,
getProcessCommand: () =>
'node openclaude --session-id conversation-1 --print work',
now: new Date('2026-06-15T08:05:00.000Z'),
})
expect(refreshed[0]).toMatchObject({
id: 'bg-running',
status: 'running',
updatedAt: '2026-06-15T08:00:00.000Z',
})
})
it('keeps PR-resume sessions fresh when the live command matches the stored invocation', async () => {
await createBackgroundSession({
id: 'bg-from-pr',
pid: 333,
cwd: '/repo',
command: ['openclaude', '--from-pr', '1642', '--print'],
sessionId: '550e8400-e29b-41d4-a716-446655440000',
now: new Date('2026-06-15T08:00:00.000Z'),
})
const refreshed = await refreshBackgroundSessionStatuses({
isProcessAlive: () => true,
getProcessCommand: () => 'node openclaude --from-pr 1642 --print',
now: new Date('2026-06-15T08:05:00.000Z'),
})
expect(refreshed[0]).toMatchObject({
id: 'bg-from-pr',
status: 'running',
updatedAt: '2026-06-15T08:00:00.000Z',
})
})
it('marks sessions stale when a live PID no longer matches the session command', async () => {
await createBackgroundSession({
id: 'bg-reused-pid',
pid: 333,
cwd: '/repo',
command: ['openclaude', '--session-id', 'conversation-1', '--print', 'work'],
sessionId: 'conversation-1',
now: new Date('2026-06-15T08:00:00.000Z'),
})
const refreshed = await refreshBackgroundSessionStatuses({
isProcessAlive: () => true,
getProcessCommand: () => 'unrelated-process',
now: new Date('2026-06-15T08:05:00.000Z'),
})
expect(refreshed[0]).toMatchObject({
id: 'bg-reused-pid',
status: 'stale',
updatedAt: '2026-06-15T08:05:00.000Z',
})
})
it('marks sessions unknown when a live PID command identity cannot be read', async () => {
await createBackgroundSession({
id: 'bg-unreadable-pid',
pid: 333,
cwd: '/repo',
command: ['openclaude', '--session-id', 'conversation-1', '--print', 'work'],
sessionId: 'conversation-1',
now: new Date('2026-06-15T08:00:00.000Z'),
})
const refreshed = await refreshBackgroundSessionStatuses({
isProcessAlive: () => true,
getProcessCommand: () => null,
now: new Date('2026-06-15T08:05:00.000Z'),
})
expect(refreshed[0]).toMatchObject({
id: 'bg-unreadable-pid',
status: 'unknown',
updatedAt: '2026-06-15T08:05:00.000Z',
})
expect(isTerminalBackgroundSession(refreshed[0]!)).toBe(false)
})
it('marks a session killed without deleting its logs or metadata', async () => {
await createBackgroundSession({
id: 'bg-kill',
pid: 444,
cwd: '/repo',
command: ['openclaude', '--print', 'work'],
sessionId: 'conversation-1',
})
const killed = await markBackgroundSessionKilled('bg-kill', {
now: new Date('2026-06-15T08:10:00.000Z'),
})
expect(killed.status).toBe('killed')
expect(killed.updatedAt).toBe('2026-06-15T08:10:00.000Z')
expect((await listBackgroundSessions()).map(s => s.id)).toEqual(['bg-kill'])
})
it('ignores malformed metadata files instead of returning unsafe sessions', async () => {
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(
join(configDir, 'bg-sessions', 'sessions', 'bad.json'),
JSON.stringify({
id: 'bg-bad',
pid: 123,
status: 'running',
}),
)
expect(await listBackgroundSessions()).toEqual([])
})
it('ignores metadata with a non-positive pid', async () => {
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(
join(configDir, 'bg-sessions', 'sessions', 'bg-zero-pid.json'),
JSON.stringify({
id: 'bg-zero-pid',
pid: 0,
cwd: '/repo',
status: 'running',
sessionId: 'conversation-1',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:00:00.000Z',
command: ['openclaude', '--print', 'work'],
stdoutLogPath: '/tmp/stdout.log',
stderrLogPath: '/tmp/stderr.log',
}),
)
expect(await listBackgroundSessions()).toEqual([])
})
it('ignores metadata whose id does not match its filename', async () => {
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
recursive: true,
})
await writeFile(
join(configDir, 'bg-sessions', 'sessions', 'bg-file.json'),
JSON.stringify({
id: 'bg-other',
pid: 123,
cwd: '/repo',
status: 'running',
sessionId: 'conversation-1',
startedAt: '2026-06-15T08:00:00.000Z',
updatedAt: '2026-06-15T08:00:00.000Z',
command: ['openclaude', '--print', 'work'],
stdoutLogPath: '/tmp/stdout.log',
stderrLogPath: '/tmp/stderr.log',
}),
)
expect(await listBackgroundSessions()).toEqual([])
})
})
+580
View File
@@ -0,0 +1,580 @@
import {
mkdir,
readFile,
readdir,
rename,
stat,
unlink,
writeFile,
} from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import { basename, join } from 'node:path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import {
getProcessCommand,
isProcessRunning,
} from '../utils/genericProcessUtils.js'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
export type BackgroundSessionStatus =
| 'running'
| 'unknown'
| 'exited'
| 'failed'
| 'stale'
| 'killed'
export type BackgroundSession = {
id: string
name?: string
pid: number
cwd: string
status: BackgroundSessionStatus
provider?: string
model?: string
sessionId: string
startedAt: string
updatedAt: string
command: string[]
stdoutLogPath: string
stderrLogPath: string
}
export type CreateBackgroundSessionInput = {
id: string
name?: string
pid: number
cwd: string
command: string[]
provider?: string
model?: string
sessionId: string
now?: Date
stdoutLogPath?: string
stderrLogPath?: string
logFilesPrecreated?: boolean
}
type BackgroundSessionNameReservation = {
name: string
id: string
creatorPid?: number
createdAt?: string
}
const TERMINAL_STATUSES = new Set<BackgroundSessionStatus>([
'exited',
'failed',
'stale',
'killed',
])
const ALL_STATUSES = new Set<BackgroundSessionStatus>([
'running',
'unknown',
...TERMINAL_STATUSES,
])
const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/
let backgroundSessionsRootForTesting: string | undefined
export function _setBackgroundSessionsRootForTesting(
root: string | undefined,
): void {
backgroundSessionsRootForTesting = root?.normalize('NFC')
}
function getBackgroundSessionsRoot(): string {
if (backgroundSessionsRootForTesting) {
return backgroundSessionsRootForTesting
}
return join(getClaudeConfigHomeDir(), 'bg-sessions')
}
function getBackgroundSessionMetadataDir(): string {
return join(getBackgroundSessionsRoot(), 'sessions')
}
function getBackgroundSessionLogsDir(): string {
return join(getBackgroundSessionsRoot(), 'logs')
}
function getBackgroundSessionNamesDir(): string {
return join(getBackgroundSessionsRoot(), 'names')
}
function metadataPathForId(id: string): string {
assertSafeId(id)
return join(getBackgroundSessionMetadataDir(), `${id}.json`)
}
function nameReservationPathForName(name: string): string {
const digest = createHash('sha256').update(name).digest('hex')
return join(getBackgroundSessionNamesDir(), `${digest}.json`)
}
function assertSafeId(id: string): void {
if (!SAFE_ID_RE.test(id)) {
throw new Error(`Invalid background session id: ${id}`)
}
}
function isErrno(error: unknown, code: string): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === code
)
}
function iso(now: Date | undefined): string {
return (now ?? new Date()).toISOString()
}
export function getBackgroundSessionLogPaths(id: string): {
stdoutLogPath: string
stderrLogPath: string
} {
assertSafeId(id)
const logsDir = getBackgroundSessionLogsDir()
return {
stdoutLogPath: join(logsDir, `${id}.out.log`),
stderrLogPath: join(logsDir, `${id}.err.log`),
}
}
export async function ensureBackgroundSessionDirs(): Promise<void> {
await mkdir(getBackgroundSessionMetadataDir(), {
recursive: true,
mode: 0o700,
})
await mkdir(getBackgroundSessionLogsDir(), { recursive: true, mode: 0o700 })
await mkdir(getBackgroundSessionNamesDir(), { recursive: true, mode: 0o700 })
}
async function writeSession(session: BackgroundSession): Promise<void> {
await ensureBackgroundSessionDirs()
const target = metadataPathForId(session.id)
const tmp = join(
getBackgroundSessionMetadataDir(),
`${session.id}.${process.pid}.${randomUUID()}.tmp`,
)
try {
await writeFile(tmp, jsonStringify(session), { flag: 'wx' })
await rename(tmp, target)
if (session.name && isTerminalBackgroundSession(session)) {
await releaseNameReservation(session.name, session.id)
}
} catch (error) {
await unlink(tmp).catch(() => {})
throw error
}
}
async function writeNewSession(session: BackgroundSession): Promise<void> {
await ensureBackgroundSessionDirs()
try {
await writeFile(metadataPathForId(session.id), jsonStringify(session), {
flag: 'wx',
})
} catch (error) {
if (isErrno(error, 'EEXIST')) {
throw new Error(`Background session id "${session.id}" already exists`)
}
throw error
}
}
async function readSessionFile(path: string): Promise<BackgroundSession | null> {
try {
const parsed = jsonParse(await readFile(path, 'utf8'))
return isBackgroundSession(parsed, basename(path, '.json')) ? parsed : null
} catch {
return null
}
}
async function readNameReservation(
path: string,
): Promise<BackgroundSessionNameReservation | null> {
try {
const parsed = jsonParse(await readFile(path, 'utf8'))
const candidate = parsed as Partial<BackgroundSessionNameReservation>
if (
parsed &&
typeof parsed === 'object' &&
typeof candidate.name === 'string' &&
typeof candidate.id === 'string' &&
SAFE_ID_RE.test(candidate.id) &&
(candidate.creatorPid === undefined ||
(typeof candidate.creatorPid === 'number' &&
Number.isInteger(candidate.creatorPid) &&
candidate.creatorPid > 1)) &&
(candidate.createdAt === undefined ||
typeof candidate.createdAt === 'string')
) {
return parsed as BackgroundSessionNameReservation
}
} catch {
// Malformed reservations are treated as recoverable orphans below.
}
return null
}
async function releaseNameReservation(
name: string,
id: string,
): Promise<void> {
const path = nameReservationPathForName(name)
const existing = await readNameReservation(path)
if (existing?.id !== id) return
await unlink(path).catch(() => {})
}
async function unlinkStaleNameReservation(path: string): Promise<void> {
try {
await unlink(path)
} catch (error) {
if (!isErrno(error, 'ENOENT')) throw error
}
}
async function releaseStaleNameReservation(
name: string,
id: string,
): Promise<void> {
const path = nameReservationPathForName(name)
const existing = await readNameReservation(path)
if (existing?.id !== id) return
await unlinkStaleNameReservation(path)
}
async function isLiveNameReservation(
name: string,
reservation: BackgroundSessionNameReservation | null,
): Promise<boolean> {
if (!reservation) return false
if (reservation.name !== name) return false
const owner = await readSessionFile(metadataPathForId(reservation.id))
if (owner) {
return owner.name === name && !isTerminalBackgroundSession(owner)
}
return (
typeof reservation.creatorPid === 'number' &&
isProcessRunning(reservation.creatorPid)
)
}
async function reserveBackgroundSessionName(
name: string,
id: string,
): Promise<() => Promise<void>> {
const path = nameReservationPathForName(name)
const reservation = jsonStringify({
name,
id,
creatorPid: process.pid,
createdAt: iso(undefined),
})
while (true) {
try {
await writeFile(path, reservation, { flag: 'wx' })
return () => releaseNameReservation(name, id)
} catch (error) {
if (!isErrno(error, 'EEXIST')) throw error
const existing = await readNameReservation(path)
if (!(await isLiveNameReservation(name, existing))) {
if (existing) {
await releaseStaleNameReservation(name, existing.id)
} else {
await unlinkStaleNameReservation(path)
}
continue
}
const suffix =
existing && existing.name === name ? ` (${existing.id})` : ''
throw new Error(
`Background session name "${name}" already exists${suffix}`,
)
}
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string')
}
function isBackgroundSession(
value: unknown,
expectedId: string,
): value is BackgroundSession {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<BackgroundSession>
return (
typeof candidate.id === 'string' &&
SAFE_ID_RE.test(candidate.id) &&
candidate.id === expectedId &&
typeof candidate.pid === 'number' &&
Number.isInteger(candidate.pid) &&
candidate.pid > 0 &&
typeof candidate.cwd === 'string' &&
typeof candidate.status === 'string' &&
ALL_STATUSES.has(candidate.status as BackgroundSessionStatus) &&
(candidate.name === undefined || typeof candidate.name === 'string') &&
(candidate.provider === undefined ||
typeof candidate.provider === 'string') &&
(candidate.model === undefined || typeof candidate.model === 'string') &&
typeof candidate.sessionId === 'string' &&
typeof candidate.startedAt === 'string' &&
typeof candidate.updatedAt === 'string' &&
isStringArray(candidate.command) &&
typeof candidate.stdoutLogPath === 'string' &&
typeof candidate.stderrLogPath === 'string'
)
}
export async function listBackgroundSessions(): Promise<BackgroundSession[]> {
let entries: string[]
try {
entries = await readdir(getBackgroundSessionMetadataDir())
} catch {
return []
}
const sessions: BackgroundSession[] = []
for (const entry of entries) {
if (!entry.endsWith('.json')) continue
const session = await readSessionFile(
join(getBackgroundSessionMetadataDir(), entry),
)
if (session) sessions.push(session)
}
return sessions.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
}
export async function assertBackgroundSessionNameAvailable(
name: string | undefined,
): Promise<void> {
if (!name) return
const existing = (await listBackgroundSessions()).find(
s => s.name === name && !isTerminalBackgroundSession(s),
)
if (existing) {
throw new Error(
`Background session name "${name}" already exists (${existing.id})`,
)
}
}
export async function createBackgroundSession(
input: CreateBackgroundSessionInput,
): Promise<BackgroundSession> {
if (!Number.isInteger(input.pid) || input.pid <= 0) {
throw new Error(`Invalid background session pid: ${input.pid}`)
}
await assertBackgroundSessionNameAvailable(input.name)
const timestamp = iso(input.now)
const logPaths = getBackgroundSessionLogPaths(input.id)
const session: BackgroundSession = {
id: input.id,
...(input.name ? { name: input.name } : {}),
pid: input.pid,
cwd: input.cwd,
status: 'running',
...(input.provider ? { provider: input.provider } : {}),
...(input.model ? { model: input.model } : {}),
sessionId: input.sessionId,
startedAt: timestamp,
updatedAt: timestamp,
command: input.command,
stdoutLogPath: input.stdoutLogPath ?? logPaths.stdoutLogPath,
stderrLogPath: input.stderrLogPath ?? logPaths.stderrLogPath,
}
await ensureBackgroundSessionDirs()
let createdStdoutLog = false
let createdStderrLog = false
let releaseReservedName: (() => Promise<void>) | undefined
try {
releaseReservedName = input.name
? await reserveBackgroundSessionName(input.name, input.id)
: undefined
if (input.logFilesPrecreated) {
if (!(await backgroundSessionLogExists(session.stdoutLogPath))) {
throw new Error(
`Background session log file does not exist: ${session.stdoutLogPath}`,
)
}
if (!(await backgroundSessionLogExists(session.stderrLogPath))) {
throw new Error(
`Background session log file does not exist: ${session.stderrLogPath}`,
)
}
} else {
await writeFile(session.stdoutLogPath, '', { flag: 'wx' })
createdStdoutLog = true
await writeFile(session.stderrLogPath, '', { flag: 'wx' })
createdStderrLog = true
}
await writeNewSession(session)
} catch (error) {
if (createdStdoutLog) await unlink(session.stdoutLogPath).catch(() => {})
if (createdStderrLog) await unlink(session.stderrLogPath).catch(() => {})
await releaseReservedName?.()
if (isErrno(error, 'EEXIST')) {
throw new Error(`Background session id "${session.id}" already exists`)
}
throw error
}
return session
}
export async function resolveBackgroundSession(
target: string,
): Promise<BackgroundSession> {
const sessions = await listBackgroundSessions()
const exactId = sessions.filter(s => s.id === target)
if (exactId.length === 1) return exactId[0]
const idPrefix = sessions.filter(s => s.id.startsWith(target))
if (idPrefix.length === 1) return idPrefix[0]
if (idPrefix.length > 1) {
throw new Error(`Background session id "${target}" is ambiguous`)
}
const byName = sessions.filter(s => s.name === target)
const liveByName = byName.filter(s => !isTerminalBackgroundSession(s))
if (liveByName.length === 1) return liveByName[0]
if (liveByName.length > 1) {
throw new Error(`Background session name "${target}" is ambiguous`)
}
if (byName.length === 1) return byName[0]
if (byName.length > 1) {
throw new Error(`Background session name "${target}" is ambiguous`)
}
throw new Error(`No background session found for "${target}"`)
}
export async function refreshBackgroundSessionStatuses(options?: {
isProcessAlive?: (pid: number) => boolean
getProcessCommand?: (pid: number) => string | null
now?: Date
}): Promise<BackgroundSession[]> {
const timestamp = iso(options?.now)
const sessions = await listBackgroundSessions()
const refreshed: BackgroundSession[] = []
for (const session of sessions) {
if (session.status !== 'running' && session.status !== 'unknown') {
refreshed.push(session)
continue
}
const processState = getBackgroundSessionProcessState(session, options)
const nextStatus: BackgroundSessionStatus =
processState === 'alive'
? 'running'
: processState === 'unknown'
? 'unknown'
: 'stale'
if (session.status !== nextStatus) {
const updated = {
...session,
status: nextStatus,
updatedAt: timestamp,
}
await writeSession(updated)
refreshed.push(updated)
continue
}
refreshed.push(session)
}
return refreshed
}
type BackgroundSessionProcessState = 'alive' | 'dead' | 'unknown'
function commandLineContainsArgs(commandLine: string, args: string[]): boolean {
if (args.length === 0) return false
let offset = 0
for (const arg of args) {
const index = commandLine.indexOf(arg, offset)
if (index === -1) return false
offset = index + arg.length
}
return true
}
function commandLineMatchesBackgroundSession(
commandLine: string,
session: BackgroundSession,
): boolean {
if (commandLine.includes(session.sessionId)) return true
// PR resume launches write to the resumed transcript id without carrying
// that id on argv, so use the stored launch invocation as the PID guard.
return commandLineContainsArgs(commandLine, session.command)
}
function getBackgroundSessionProcessState(
session: BackgroundSession,
options?: {
isProcessAlive?: (pid: number) => boolean
getProcessCommand?: (pid: number) => string | null
},
): BackgroundSessionProcessState {
const isAlive = options?.isProcessAlive ?? isProcessRunning
if (!isAlive(session.pid)) return 'dead'
const readCommand = options?.getProcessCommand ?? getProcessCommand
const command = readCommand(session.pid)
if (command == null) return 'unknown'
return commandLineMatchesBackgroundSession(command, session) ? 'alive' : 'dead'
}
export function isBackgroundSessionProcessAlive(
session: BackgroundSession,
options?: {
isProcessAlive?: (pid: number) => boolean
getProcessCommand?: (pid: number) => string | null
},
): boolean {
return getBackgroundSessionProcessState(session, options) === 'alive'
}
export async function markBackgroundSessionKilled(
target: string,
options?: { now?: Date },
): Promise<BackgroundSession> {
const session = await resolveBackgroundSession(target)
const updated: BackgroundSession = {
...session,
status: 'killed',
updatedAt: iso(options?.now),
}
await writeSession(updated)
return updated
}
export async function backgroundSessionLogExists(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile()
} catch {
return false
}
}
export function isTerminalBackgroundSession(
session: BackgroundSession,
): boolean {
return TERMINAL_STATUSES.has(session.status)
}
+91 -57
View File
@@ -68,6 +68,7 @@ import type { Stream } from 'src/utils/stream.js'
import { EMPTY_USAGE } from 'src/services/api/logging.js'
import {
loadConversationForResume,
loadConversationForResumeFromPr,
type TurnInterruptionState,
} from 'src/utils/conversationRecovery.js'
import type {
@@ -455,6 +456,7 @@ export async function runHeadless(
options: {
continue: boolean | undefined
resume: string | boolean | undefined
fromPr: string | boolean | undefined
resumeSessionAt: string | undefined
verbose: boolean | undefined
outputFormat: string | undefined
@@ -563,14 +565,20 @@ export async function runHeadless(
// Without this, the disk cache is empty and all flags fall back to defaults.
void initializeGrowthBook()
if (options.resumeSessionAt && !options.resume) {
process.stderr.write(`Error: --resume-session-at requires --resume\n`)
const hasRequestedResumeSource = Boolean(options.resume || options.fromPr)
if (options.resumeSessionAt && !hasRequestedResumeSource) {
process.stderr.write(
`Error: --resume-session-at requires --resume or --from-pr\n`,
)
gracefulShutdownSync(1)
return
}
if (options.rewindFiles && !options.resume) {
process.stderr.write(`Error: --rewind-files requires --resume\n`)
if (options.rewindFiles && !hasRequestedResumeSource) {
process.stderr.write(
`Error: --rewind-files requires --resume or --from-pr\n`,
)
gracefulShutdownSync(1)
return
}
@@ -687,6 +695,7 @@ export async function runHeadless(
continue: options.continue,
teleport: options.teleport,
resume: options.resume,
fromPr: options.fromPr,
resumeSessionAt: options.resumeSessionAt,
forkSession: options.forkSession,
outputFormat: options.outputFormat,
@@ -774,9 +783,11 @@ export async function runHeadless(
const hasValidResumeSessionId =
typeof options.resume === 'string' &&
(Boolean(validateUuid(options.resume)) || options.resume.endsWith('.jsonl'))
const hasValidResumeSource =
hasValidResumeSessionId || Boolean(options.fromPr)
const isUsingSdkUrl = Boolean(options.sdkUrl)
if (!inputPrompt && !hasValidResumeSessionId && !isUsingSdkUrl) {
if (!inputPrompt && !hasValidResumeSource && !isUsingSdkUrl) {
process.stderr.write(
`Error: Input must be provided either through stdin or as a prompt argument when using --print\n`,
)
@@ -4909,6 +4920,7 @@ async function loadInitialMessages(
continue: boolean | undefined
teleport: string | true | null | undefined
resume: string | boolean | undefined
fromPr: string | boolean | undefined
resumeSessionAt: string | undefined
forkSession: boolean | undefined
outputFormat: string | undefined
@@ -5037,62 +5049,82 @@ async function loadInitialMessages(
}
}
// Handle resume in print mode (accepts session ID or URL)
// Handle resume in print mode (accepts session ID, URL, or PR selector)
// URLs are [internal-only]
if (options.resume) {
if (options.resume || options.fromPr) {
try {
logEvent('tengu_resume_print', {})
let result: Awaited<ReturnType<typeof loadConversationForResume>> = null
let parsedSessionId: ReturnType<typeof parseSessionIdentifier> = null
// In print mode - we require a valid session ID, JSONL file or URL
const parsedSessionId = parseSessionIdentifier(
typeof options.resume === 'string' ? options.resume : '',
)
if (!parsedSessionId) {
let errorMessage =
'Error: --resume requires a valid session ID when used with --print. Usage: openclaude -p --resume <session-id>'
if (typeof options.resume === 'string') {
errorMessage += `. Session IDs must be in UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000). Provided value "${options.resume}" is not a valid UUID`
}
emitLoadError(errorMessage, options.outputFormat)
gracefulShutdownSync(1)
return { messages: [] }
}
if (options.resume) {
logEvent('tengu_resume_print', {})
// Hydrate local transcript from remote before loading
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) {
// Await restore alongside hydration so SSE catchup lands on
// restored state, not a fresh default.
const [, metadata] = await Promise.all([
hydrateFromCCRv2InternalEvents(parsedSessionId.sessionId),
options.restoredWorkerState,
])
if (metadata) {
const sanitizedMetadata = await sanitizeResumedExternalMetadata(
metadata,
options.getAppState().toolPermissionContext,
)
setAppState(externalMetadataToAppState(sanitizedMetadata))
if (typeof metadata.model === 'string') {
setMainLoopModelOverride(metadata.model)
}
}
} else if (
parsedSessionId.isUrl &&
parsedSessionId.ingressUrl &&
isEnvTruthy(process.env.ENABLE_SESSION_PERSISTENCE)
) {
// v1: fetch session logs from Session Ingress
await hydrateRemoteSession(
parsedSessionId.sessionId,
parsedSessionId.ingressUrl,
// In print mode - we require a valid session ID, JSONL file or URL
parsedSessionId = parseSessionIdentifier(
typeof options.resume === 'string' ? options.resume : '',
)
}
if (!parsedSessionId) {
let errorMessage =
'Error: --resume requires a valid session ID when used with --print. Usage: openclaude -p --resume <session-id>'
if (typeof options.resume === 'string') {
errorMessage += `. Session IDs must be in UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000). Provided value "${options.resume}" is not a valid UUID`
}
emitLoadError(errorMessage, options.outputFormat)
gracefulShutdownSync(1)
return { messages: [] }
}
// Load the conversation with the specified session ID
const result = await loadConversationForResume(
parsedSessionId.sessionId,
parsedSessionId.jsonlFile || undefined,
)
// Hydrate local transcript from remote before loading
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) {
// Await restore alongside hydration so SSE catchup lands on
// restored state, not a fresh default.
const [, metadata] = await Promise.all([
hydrateFromCCRv2InternalEvents(parsedSessionId.sessionId),
options.restoredWorkerState,
])
if (metadata) {
const sanitizedMetadata = await sanitizeResumedExternalMetadata(
metadata,
options.getAppState().toolPermissionContext,
)
setAppState(externalMetadataToAppState(sanitizedMetadata))
if (typeof metadata.model === 'string') {
setMainLoopModelOverride(metadata.model)
}
}
} else if (
parsedSessionId.isUrl &&
parsedSessionId.ingressUrl &&
isEnvTruthy(process.env.ENABLE_SESSION_PERSISTENCE)
) {
// v1: fetch session logs from Session Ingress
await hydrateRemoteSession(
parsedSessionId.sessionId,
parsedSessionId.ingressUrl,
)
}
// Load the conversation with the specified session ID
result = await loadConversationForResume(
parsedSessionId.sessionId,
parsedSessionId.jsonlFile || undefined,
)
} else if (options.fromPr) {
logEvent('tengu_resume_from_pr_print', {})
const selector =
options.fromPr === true ? true : String(options.fromPr)
result = await loadConversationForResumeFromPr(selector)
if (!result || result.messages.length === 0) {
const description =
selector === true ? 'any PR' : `PR selector: ${selector}`
emitLoadError(
`No conversation found linked to ${description}`,
options.outputFormat,
)
gracefulShutdownSync(1)
return { messages: [] }
}
}
// hydrateFromCCRv2InternalEvents writes an empty transcript file for
// fresh sessions (writeFile(sessionFile, '') with zero events), so
@@ -5101,7 +5133,7 @@ async function loadInitialMessages(
if (!result || result.messages.length === 0) {
// For URL-based or CCR v2 resume, start with empty session (it was hydrated but empty)
if (
parsedSessionId.isUrl ||
parsedSessionId?.isUrl ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)
) {
// Execute SessionStart hooks for startup since we're starting a new session
@@ -5111,7 +5143,9 @@ async function loadInitialMessages(
}
} else {
emitLoadError(
`No conversation found with session ID: ${parsedSessionId.sessionId}`,
parsedSessionId
? `No conversation found with session ID: ${parsedSessionId.sessionId}`
: 'No conversation found for selected PR-linked session',
options.outputFormat,
)
gracefulShutdownSync(1)
+256 -1
View File
@@ -3,7 +3,79 @@
* Closes: Gitlawb/openclaude#402 — JavaScript heap OOM during large tasks
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
} from 'bun:test'
type CliMain = typeof import('./cli.js')['main']
let runCliEntrypoint: CliMain
const mockProfileCheckpoint = mock((_checkpoint: string) => {})
const mockPsHandler = mock(async (_args: string[]) => {})
const mockLogsHandler = mock(async (_args: string[]) => {})
const mockAttachHandler = mock(async (_args: string[]) => {})
const mockKillHandler = mock(async (_args: string[]) => {})
const mockHandleBgFlag = mock(async (_args: string[]) => {})
const mockEnableConfigs = mock(() => {})
const mockApplySafeConfigEnvironmentVariables = mock(() => {})
const mockApplyStartupEnvFromProfile = mock(
async (_input: {
processEnv: NodeJS.ProcessEnv
onValidationError: (message: string) => void
}) => {},
)
const mockGetProviderValidationError = mock(
async (_env: NodeJS.ProcessEnv) => undefined,
)
const mockEagerLoadSettingsFromArgs = mock((_args: string[]) => ({ ok: true }))
const mockResolveOutOfProcessTeammateProviderFromCliArgs = mock(
(_args: string[], _settings: unknown) => undefined,
)
const mockApplyAgentProviderOverrideToEnv = mock((_override: unknown) => {})
const mockGetInitialSettings = mock(() => ({}))
const mockRefreshGithubModelsTokenIfNeeded = mock(async () => {})
const mockHydrateGithubModelsTokenFromSecureStorage = mock(() => {})
const mockValidateProviderEnvForStartupOrExit = mock(async () => {})
const mockPrintStartupScreen = mock((_model: string | undefined) => {})
const mockStartCapturingEarlyInput = mock(() => {})
const mockCliMain = mock(async () => {})
const runtimeMocks = [
mockProfileCheckpoint,
mockPsHandler,
mockLogsHandler,
mockAttachHandler,
mockKillHandler,
mockHandleBgFlag,
mockEnableConfigs,
mockApplySafeConfigEnvironmentVariables,
mockApplyStartupEnvFromProfile,
mockGetProviderValidationError,
mockEagerLoadSettingsFromArgs,
mockResolveOutOfProcessTeammateProviderFromCliArgs,
mockApplyAgentProviderOverrideToEnv,
mockGetInitialSettings,
mockRefreshGithubModelsTokenIfNeeded,
mockHydrateGithubModelsTokenFromSecureStorage,
mockValidateProviderEnvForStartupOrExit,
mockPrintStartupScreen,
mockStartCapturingEarlyInput,
mockCliMain,
]
function clearRuntimeMocks() {
for (const fn of runtimeMocks) {
fn.mockClear()
}
}
describe('cli.tsx — NODE_OPTIONS --max-old-space-size (issue #402)', () => {
const originalNodeOptions = process.env.NODE_OPTIONS
@@ -85,4 +157,187 @@ describe('cli.tsx — --provider startup ordering', () => {
expect(safeReapplyIndex).toBeLessThan(configApplyIndex)
expect(configReapplyIndex).toBeGreaterThan(configApplyIndex)
})
it('dispatches background session management before config and provider validation', async () => {
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
const bgManagementIndex = src.indexOf("args[0] === 'ps'")
const configEnableIndex = src.indexOf('enableConfigs()')
const providerValidationIndex = src.indexOf(
'await validateProviderEnvForStartupOrExit()',
)
expect(bgManagementIndex).toBeGreaterThanOrEqual(0)
expect(configEnableIndex).toBeGreaterThanOrEqual(0)
expect(providerValidationIndex).toBeGreaterThanOrEqual(0)
expect(bgManagementIndex).toBeLessThan(configEnableIndex)
expect(bgManagementIndex).toBeLessThan(providerValidationIndex)
})
it('keeps background spawn after profile routing but before provider validation', async () => {
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
const profileApplyIndex = src.indexOf('await applyStartupEnvFromProfile')
const bgFlagIndex = src.indexOf("optionArgs.includes('--bg')")
const providerValidationIndex = src.indexOf(
'await validateProviderEnvForStartupOrExit()',
)
expect(profileApplyIndex).toBeGreaterThanOrEqual(0)
expect(bgFlagIndex).toBeGreaterThanOrEqual(0)
expect(providerValidationIndex).toBeGreaterThanOrEqual(0)
expect(bgFlagIndex).toBeGreaterThan(profileApplyIndex)
expect(bgFlagIndex).toBeLessThan(providerValidationIndex)
})
})
describe('cli.tsx — background routing behavior', () => {
const bgOptions = {
bgSessionsEnabled: true,
importers: {
startupProfiler: async () => ({
profileCheckpoint: mockProfileCheckpoint,
}),
bg: async () => ({
psHandler: mockPsHandler,
logsHandler: mockLogsHandler,
attachHandler: mockAttachHandler,
killHandler: mockKillHandler,
handleBgFlag: mockHandleBgFlag,
}),
config: async () => ({
enableConfigs: mockEnableConfigs,
}),
managedEnv: async () => ({
applySafeConfigEnvironmentVariables:
mockApplySafeConfigEnvironmentVariables,
}),
providerProfile: async () => ({
applyStartupEnvFromProfile: mockApplyStartupEnvFromProfile,
}),
providerValidation: async () => ({
getProviderValidationError: mockGetProviderValidationError,
validateProviderEnvForStartupOrExit:
mockValidateProviderEnvForStartupOrExit,
}),
flagSettings: async () => ({
eagerLoadSettingsFromArgs: mockEagerLoadSettingsFromArgs,
}),
agentRouting: async () => ({
applyAgentProviderOverrideToEnv: mockApplyAgentProviderOverrideToEnv,
resolveOutOfProcessTeammateProviderFromCliArgs:
mockResolveOutOfProcessTeammateProviderFromCliArgs,
}),
settings: async () => ({
getInitialSettings: mockGetInitialSettings,
}),
githubModelsCredentials: async () => ({
hydrateGithubModelsTokenFromSecureStorage:
mockHydrateGithubModelsTokenFromSecureStorage,
refreshGithubModelsTokenIfNeeded: mockRefreshGithubModelsTokenIfNeeded,
}),
startupScreen: async () => ({
printStartupScreen: mockPrintStartupScreen,
}),
earlyInput: async () => ({
startCapturingEarlyInput: mockStartCapturingEarlyInput,
}),
main: async () => ({
main: mockCliMain,
}),
},
} as unknown as Parameters<CliMain>[1]
const originalAutoRunGuard =
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
beforeAll(async () => {
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = '1'
const entrypoint = await import('./cli.js')
runCliEntrypoint = entrypoint.main
})
afterAll(() => {
if (originalAutoRunGuard === undefined) {
delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
} else {
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN =
originalAutoRunGuard
}
})
beforeEach(() => {
clearRuntimeMocks()
})
it('dispatches background management commands before startup work', async () => {
const cases: Array<[string, typeof mockPsHandler, string[]]> = [
['ps', mockPsHandler, ['--json']],
['logs', mockLogsHandler, ['session-1', '-f']],
['attach', mockAttachHandler, ['session-1']],
['kill', mockKillHandler, ['session-1']],
]
for (const [command, handler, tail] of cases) {
clearRuntimeMocks()
await runCliEntrypoint([command, ...tail], bgOptions)
expect(handler.mock.calls).toEqual([[tail]])
expect(mockHandleBgFlag).not.toHaveBeenCalled()
expect(mockEnableConfigs).not.toHaveBeenCalled()
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
expect(mockCliMain).not.toHaveBeenCalled()
}
})
it('keeps management commands on the management path even with --bg arguments', async () => {
const cases: Array<[string, typeof mockPsHandler]> = [
['ps', mockPsHandler],
['logs', mockLogsHandler],
['attach', mockAttachHandler],
['kill', mockKillHandler],
]
for (const [command, handler] of cases) {
clearRuntimeMocks()
await runCliEntrypoint([command, '--bg', 'session-1'], bgOptions)
expect(handler.mock.calls).toEqual([[['--bg', 'session-1']]])
expect(mockHandleBgFlag).not.toHaveBeenCalled()
expect(mockEnableConfigs).not.toHaveBeenCalled()
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
expect(mockCliMain).not.toHaveBeenCalled()
}
})
it('routes real background flags after profile routing without provider validation', async () => {
const args = ['--background', '--', '--print']
await runCliEntrypoint(args, bgOptions)
expect(mockEnableConfigs).toHaveBeenCalledTimes(1)
expect(mockApplySafeConfigEnvironmentVariables).toHaveBeenCalledTimes(1)
expect(mockApplyStartupEnvFromProfile).toHaveBeenCalledTimes(1)
expect(mockEagerLoadSettingsFromArgs.mock.calls).toEqual([[args]])
expect(mockHandleBgFlag.mock.calls).toEqual([[args]])
expect(mockRefreshGithubModelsTokenIfNeeded).not.toHaveBeenCalled()
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
expect(mockCliMain).not.toHaveBeenCalled()
})
it('treats --bg after -- as positional text, not a background flag', async () => {
const args = ['--', '--bg']
await runCliEntrypoint(args, bgOptions)
expect(mockHandleBgFlag).not.toHaveBeenCalled()
expect(mockRefreshGithubModelsTokenIfNeeded).toHaveBeenCalledTimes(1)
expect(mockHydrateGithubModelsTokenFromSecureStorage).toHaveBeenCalledTimes(
1,
)
expect(mockValidateProviderEnvForStartupOrExit).toHaveBeenCalledTimes(1)
expect(mockPrintStartupScreen).toHaveBeenCalledTimes(1)
expect(mockCliMain).toHaveBeenCalledTimes(1)
})
})
+134 -54
View File
@@ -67,8 +67,77 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
* All imports are dynamic to minimize module evaluation for fast paths.
* Fast-path for --version has zero imports beyond this file.
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
type CliEntrypointOptions = {
bgSessionsEnabled?: boolean
importers?: Partial<CliEntrypointImporters>
}
type CliEntrypointImporters = {
startupProfiler: () => Promise<typeof import('../utils/startupProfiler.js')>
bg: () => Promise<typeof import('../cli/bg.js')>
providerFlag: () => Promise<typeof import('../utils/providerFlag.js')>
config: () => Promise<typeof import('../utils/config.js')>
managedEnv: () => Promise<typeof import('../utils/managedEnv.js')>
providerProfile: () => Promise<typeof import('../utils/providerProfile.js')>
providerValidation: () => Promise<
typeof import('../utils/providerValidation.js')
>
flagSettings: () => Promise<
typeof import('../utils/settings/flagSettings.js')
>
agentRouting: () => Promise<
typeof import('../services/api/agentRouting.js')
>
settings: () => Promise<typeof import('../utils/settings/settings.js')>
cliArgs: () => Promise<typeof import('../utils/cliArgs.js')>
githubModelsCredentials: () => Promise<
typeof import('../utils/githubModelsCredentials.js')
>
startupScreen: () => Promise<typeof import('../components/StartupScreen.js')>
earlyInput: () => Promise<typeof import('../utils/earlyInput.js')>
main: () => Promise<typeof import('../main.js')>
}
const defaultCliEntrypointImporters: CliEntrypointImporters = {
startupProfiler: () => import('../utils/startupProfiler.js'),
bg: () => import('../cli/bg.js'),
providerFlag: () => import('../utils/providerFlag.js'),
config: () => import('../utils/config.js'),
managedEnv: () => import('../utils/managedEnv.js'),
providerProfile: () => import('../utils/providerProfile.js'),
providerValidation: () => import('../utils/providerValidation.js'),
flagSettings: () => import('../utils/settings/flagSettings.js'),
agentRouting: () => import('../services/api/agentRouting.js'),
settings: () => import('../utils/settings/settings.js'),
cliArgs: () => import('../utils/cliArgs.js'),
githubModelsCredentials: () =>
import('../utils/githubModelsCredentials.js'),
startupScreen: () => import('../components/StartupScreen.js'),
earlyInput: () => import('../utils/earlyInput.js'),
main: () => import('../main.js'),
}
function getCliEntrypointImporters(
overrides: Partial<CliEntrypointImporters> | undefined,
): CliEntrypointImporters {
return {
...defaultCliEntrypointImporters,
...overrides,
}
}
function isBgSessionsEnabled(options: CliEntrypointOptions): boolean {
if (options.bgSessionsEnabled !== undefined) return options.bgSessionsEnabled
if (feature('BG_SESSIONS')) return true
return false
}
export async function main(
args: string[] = process.argv.slice(2),
options: CliEntrypointOptions = {},
): Promise<void> {
const bgSessionsEnabled = isBgSessionsEnabled(options)
const importers = getCliEntrypointImporters(options.importers)
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
@@ -78,10 +147,36 @@ async function main(): Promise<void> {
return;
}
// Fast-path for `openclaude ps|logs|attach|kill`.
// Session management is entirely local, so it should not require config,
// profile, credential, provider-validation, or startup-screen work.
if (bgSessionsEnabled && (args[0] === 'ps' || args[0] === 'logs' || args[0] === 'attach' || args[0] === 'kill')) {
const {
profileCheckpoint
} = await importers.startupProfiler();
profileCheckpoint('cli_bg_path');
const bg = await importers.bg();
switch (args[0]) {
case 'ps':
await bg.psHandler(args.slice(1));
break;
case 'logs':
await bg.logsHandler(args.slice(1));
break;
case 'attach':
await bg.attachHandler(args.slice(1));
break;
case 'kill':
await bg.killHandler(args.slice(1));
break;
}
return;
}
// --provider: set provider env vars early so saved-profile resolution,
// validation, and the startup banner all see the intended provider/model.
if (args.includes('--provider')) {
const { applyProviderFlagFromArgs } = await import('../utils/providerFlag.js');
const { applyProviderFlagFromArgs } = await importers.providerFlag();
const result = applyProviderFlagFromArgs(args, {
rememberForSettingsEnv: true,
});
@@ -94,19 +189,18 @@ async function main(): Promise<void> {
// Enable configs first so we can read settings
{
const { enableConfigs } = await import('../utils/config.js')
const { enableConfigs } = await importers.config()
enableConfigs()
}
// Apply settings.env from user settings (includes GitHub provider settings from /onboard-github)
{
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
const { applySafeConfigEnvironmentVariables } =
await importers.managedEnv()
applySafeConfigEnvironmentVariables()
}
const { applyStartupEnvFromProfile } = await import(
'../utils/providerProfile.js'
)
const { applyStartupEnvFromProfile } = await importers.providerProfile()
await applyStartupEnvFromProfile({
processEnv: process.env,
onValidationError: message => {
@@ -118,9 +212,7 @@ async function main(): Promise<void> {
// selected a configured agentModels key, apply that route before provider
// validation and --model env routing run in this child process.
{
const { eagerLoadSettingsFromArgs } = await import(
'../utils/settings/flagSettings.js'
)
const { eagerLoadSettingsFromArgs } = await importers.flagSettings()
const settingsLoadResult = eagerLoadSettingsFromArgs(args)
if (!settingsLoadResult.ok) {
if (settingsLoadResult.cause instanceof Error) {
@@ -135,8 +227,8 @@ async function main(): Promise<void> {
const {
applyAgentProviderOverrideToEnv,
resolveOutOfProcessTeammateProviderFromCliArgs,
} = await import('../services/api/agentRouting.js')
const { getInitialSettings } = await import('../utils/settings/settings.js')
} = await importers.agentRouting()
const { getInitialSettings } = await importers.settings()
const providerOverride = resolveOutOfProcessTeammateProviderFromCliArgs(
args,
getInitialSettings(),
@@ -146,40 +238,55 @@ async function main(): Promise<void> {
}
}
// Fast-path for `--bg`/`--background` after profile routing has been applied
// so the spawned child inherits the selected provider/model environment.
if (bgSessionsEnabled) {
const { argsBeforeDelimiter } = await importers.cliArgs()
const optionArgs = argsBeforeDelimiter(args)
if (optionArgs.includes('--bg') || optionArgs.includes('--background')) {
const {
profileCheckpoint
} = await importers.startupProfiler();
profileCheckpoint('cli_bg_path');
const bg = await importers.bg();
await bg.handleBgFlag(args);
return;
}
}
// Hydrate GitHub credentials after profile is applied so CLAUDE_CODE_USE_GITHUB from profile is available
{
const {
hydrateGithubModelsTokenFromSecureStorage,
refreshGithubModelsTokenIfNeeded,
} = await import('../utils/githubModelsCredentials.js')
} = await importers.githubModelsCredentials()
await refreshGithubModelsTokenIfNeeded()
hydrateGithubModelsTokenFromSecureStorage()
}
const { validateProviderEnvForStartupOrExit } = await import(
'../utils/providerValidation.js'
)
const { validateProviderEnvForStartupOrExit } =
await importers.providerValidation()
await validateProviderEnvForStartupOrExit()
// #808: --model alone (no --provider) — route to the env var matching the
// active provider before the banner prints so the override is visible.
if (args.includes('--model')) {
const { applyModelFlagFromArgs } = await import('../utils/providerFlag.js')
const { applyModelFlagFromArgs } = await importers.providerFlag()
applyModelFlagFromArgs(args)
}
// Parse --model early so the startup screen can display the override
const { eagerParseCliFlag } = await import('../utils/cliArgs.js')
const { eagerParseCliFlag } = await importers.cliArgs()
const earlyModelFlag = eagerParseCliFlag('--model')
// Print the gradient startup screen before the Ink UI loads
const { printStartupScreen } = await import('../components/StartupScreen.js')
const { printStartupScreen } = await importers.startupScreen()
printStartupScreen(earlyModelFlag)
// For all other paths, load the startup profiler
const {
profileCheckpoint
} = await import('../utils/startupProfiler.js');
} = await importers.startupProfiler();
profileCheckpoint('cli_entry');
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
@@ -314,35 +421,6 @@ async function main(): Promise<void> {
return;
}
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
// Session management against the ~/.claude/sessions/ registry. Flag
// literals are inlined so bg.js only loads when actually dispatching.
if (feature('BG_SESSIONS') && (args[0] === 'ps' || args[0] === 'logs' || args[0] === 'attach' || args[0] === 'kill' || args.includes('--bg') || args.includes('--background'))) {
profileCheckpoint('cli_bg_path');
const {
enableConfigs
} = await import('../utils/config.js');
enableConfigs();
const bg = await import('../cli/bg.js');
switch (args[0]) {
case 'ps':
await bg.psHandler(args.slice(1));
break;
case 'logs':
await bg.logsHandler(args[1]);
break;
case 'attach':
await bg.attachHandler(args[1]);
break;
case 'kill':
await bg.killHandler(args[1]);
break;
default:
await bg.handleBgFlag(args);
}
return;
}
// Fast-path for template job commands.
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) {
profileCheckpoint('cli_templates_path');
@@ -423,17 +501,19 @@ async function main(): Promise<void> {
if (process.env.OPENCLAUDE_DISABLE_EARLY_INPUT !== '1') {
const {
startCapturingEarlyInput
} = await import('../utils/earlyInput.js');
} = await importers.earlyInput();
startCapturingEarlyInput();
}
profileCheckpoint('cli_before_main_import');
const {
main: cliMain
} = await import('../main.js');
} = await importers.main();
profileCheckpoint('cli_after_main_import');
await cliMain();
profileCheckpoint('cli_after_main_complete');
}
// eslint-disable-next-line custom-rules/no-top-level-side-effects
void main();
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
if (process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN !== '1') {
void main();
}
+2 -1
View File
@@ -2516,7 +2516,7 @@ async function run(): Promise<CommanderCommand> {
// undefined and the ?? fallback runs). Also skip when setupTrigger is
// set — those paths run setup hooks first (print.ts:544), and session
// start hooks must wait until setup completes.
const sessionStartHooksPromise = options.continue || options.resume || teleport || setupTrigger ? undefined : processSessionStartHooks('startup');
const sessionStartHooksPromise = options.continue || options.resume || options.fromPr || teleport || setupTrigger ? undefined : processSessionStartHooks('startup');
// Suppress transient unhandledRejection if this rejects before
// loadInitialMessages awaits it. Downstream await still observes the
// rejection — this just prevents the spurious global handler fire.
@@ -2743,6 +2743,7 @@ async function run(): Promise<CommanderCommand> {
void runHeadless(inputPrompt, () => headlessStore.getState(), headlessStore.setState, commandsHeadless, tools, sdkMcpConfigs, agentDefinitions.activeAgents, {
continue: options.continue,
resume: options.resume,
fromPr: options.fromPr,
verbose: verbose,
outputFormat: outputFormat,
jsonSchema,
+5
View File
@@ -28,6 +28,11 @@ export function eagerParseCliFlag(
return undefined
}
export function argsBeforeDelimiter(args: string[]): string[] {
const delimiterIndex = args.indexOf('--')
return delimiterIndex === -1 ? args : args.slice(0, delimiterIndex)
}
/**
* Handle the standard Unix `--` separator convention in CLI arguments.
*
+123
View File
@@ -6,6 +6,7 @@ import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import * as realUdsClient from './udsClient.js'
import * as realProviders from './model/providers.js'
const tempDirs: string[] = []
@@ -92,6 +93,9 @@ beforeEach(async () => {
afterEach(async () => {
try {
mock.restore()
// Bun 1.3.13 can leave restored module instances visible to later test
// files, so re-register full exports after using partial module mocks.
mock.module('./udsClient.js', () => realUdsClient)
mock.module('./model/providers.js', () => realProviders)
if (originalSimple === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
@@ -190,6 +194,51 @@ test('loadConversationForResume preserves goal metadata from jsonl transcript pa
expect(result?.goal).toEqual(goal)
})
test('findResumeLogByPrSelector selects the first non-sidechain PR match', async () => {
const { findResumeLogByPrSelector } = await importFreshConversationRecovery()
const linked = {
date: ts,
messages: [user(id(12), 'linked')],
value: 0,
created: new Date(ts),
modified: new Date(ts),
firstPrompt: 'linked',
messageCount: 1,
isSidechain: false,
sessionId: id(12),
prNumber: 1642,
prUrl: 'https://github.com/Gitlawb/openclaude/pull/1642',
prRepository: 'Gitlawb/openclaude',
} as any
const sidechain = {
...linked,
isSidechain: true,
sessionId: id(13),
} as any
const unrelated = {
...linked,
sessionId: id(14),
prNumber: 17,
prUrl: 'https://github.com/Gitlawb/openclaude/pull/17',
} as any
expect(findResumeLogByPrSelector([sidechain, linked, unrelated], true)).toBe(
linked,
)
expect(
findResumeLogByPrSelector([sidechain, linked, unrelated], '1642'),
).toBe(linked)
expect(
findResumeLogByPrSelector(
[sidechain, linked, unrelated],
'https://github.com/Gitlawb/openclaude/pull/1642',
),
).toBe(linked)
expect(
findResumeLogByPrSelector([sidechain, linked, unrelated], 'missing'),
).toBeNull()
})
test('loadConversationForResume rejects oversized reconstructed transcripts', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const hugeContent = 'x'.repeat(8 * 1024 * 1024 + 32 * 1024)
@@ -212,6 +261,80 @@ test('loadConversationForResume rejects oversized reconstructed transcripts', as
)
})
test('collectLiveBackgroundSessionIds includes local registry sessions when UDS is empty', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const liveSessionId = '00000000-0000-4000-8000-000000000111'
const staleSessionId = '00000000-0000-4000-8000-000000000222'
const { collectLiveBackgroundSessionIds } =
await importFreshConversationRecovery()
expect(
await collectLiveBackgroundSessionIds({
listAllLiveSessions: async () => [],
refreshBackgroundSessionStatuses: async () => [
{
sessionId: liveSessionId,
status: 'running',
},
{
sessionId: staleSessionId,
status: 'stale',
},
],
isTerminalBackgroundSession: session => session.status !== 'running',
}),
).toEqual(new Set([liveSessionId]))
})
test('collectLiveBackgroundSessionIds falls back to registry sessions when UDS fails', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const liveSessionId = '00000000-0000-4000-8000-000000000333'
const { collectLiveBackgroundSessionIds } =
await importFreshConversationRecovery()
expect(
await collectLiveBackgroundSessionIds({
listAllLiveSessions: async () => {
throw new Error('UDS unavailable')
},
refreshBackgroundSessionStatuses: async () => [
{
sessionId: liveSessionId,
status: 'running',
},
],
isTerminalBackgroundSession: session => session.status !== 'running',
}),
).toEqual(new Set([liveSessionId]))
})
test('collectLiveBackgroundSessionIds falls back to UDS sessions when registry refresh fails', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const liveSessionId = '00000000-0000-4000-8000-000000000444'
const interactiveSessionId = '00000000-0000-4000-8000-000000000555'
const { collectLiveBackgroundSessionIds } =
await importFreshConversationRecovery()
expect(
await collectLiveBackgroundSessionIds({
listAllLiveSessions: async () => [
{
kind: 'background',
sessionId: liveSessionId,
},
{
kind: 'interactive',
sessionId: interactiveSessionId,
},
],
refreshBackgroundSessionStatuses: async () => {
throw new Error('Registry unavailable')
},
isTerminalBackgroundSession: session => session.status !== 'running',
}),
).toEqual(new Set([liveSessionId]))
})
test('deserializeMessages preserves thinking blocks for GitHub native Claude transport', async () => {
clearProviderEnv()
process.env.CLAUDE_CODE_USE_GITHUB = '1'
+101 -13
View File
@@ -83,6 +83,8 @@ const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS')
// enough room for normal compacted sessions plus resume hook context.
const MAX_RESUME_MESSAGE_BYTES = 8 * 1024 * 1024
type PrResumeSelector = true | number | string
export class ResumeTranscriptTooLargeError extends Error {
constructor(
readonly bytes: number,
@@ -223,6 +225,38 @@ function shouldPreserveThinkingBlocksForProviderReplay(): boolean {
)
}
function parsePrIdentifier(value: string): number | null {
const directNumber = parseInt(value, 10)
if (!isNaN(directNumber) && directNumber > 0) {
return directNumber
}
const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)
if (urlMatch?.[1]) {
return parseInt(urlMatch[1], 10)
}
return null
}
export function findResumeLogByPrSelector(
logs: LogOption[],
selector: PrResumeSelector,
): LogOption | null {
const candidates = logs.filter(log => !log.isSidechain)
if (selector === true) {
return candidates.find(log => log.prNumber !== undefined) ?? null
}
if (typeof selector === 'number') {
return candidates.find(log => log.prNumber === selector) ?? null
}
const prNumber = parsePrIdentifier(selector)
if (prNumber !== null) {
return candidates.find(log => log.prNumber === prNumber) ?? null
}
return null
}
/**
* Deserializes messages from a log file into the format expected by the REPL.
* Filters unresolved tool uses, orphaned thinking messages, and appends a
@@ -358,6 +392,56 @@ export function deserializeMessagesWithInterruptDetection(
}
}
type UdsClientModule = typeof import('./udsClient.js')
type BgRegistryModule = typeof import('../cli/bgRegistry.js')
type CollectLiveBackgroundSessionIdsDeps = {
listAllLiveSessions?: UdsClientModule['listAllLiveSessions']
refreshBackgroundSessionStatuses?: BgRegistryModule['refreshBackgroundSessionStatuses']
isTerminalBackgroundSession?: BgRegistryModule['isTerminalBackgroundSession']
}
export async function collectLiveBackgroundSessionIds(
deps: CollectLiveBackgroundSessionIdsDeps = {},
): Promise<Set<string>> {
const skip = new Set<string>()
try {
const listAllLiveSessions =
deps.listAllLiveSessions ??
(await import('./udsClient.js')).listAllLiveSessions
const live = await listAllLiveSessions()
for (const session of live) {
if (session.kind && session.kind !== 'interactive' && session.sessionId) {
skip.add(session.sessionId)
}
}
} catch {
// UDS unavailable — local registry below still protects local bg sessions.
}
try {
let refreshBackgroundSessionStatuses =
deps.refreshBackgroundSessionStatuses
let isTerminalBackgroundSession = deps.isTerminalBackgroundSession
if (!refreshBackgroundSessionStatuses || !isTerminalBackgroundSession) {
const bgRegistry = await import('../cli/bgRegistry.js')
refreshBackgroundSessionStatuses ??=
bgRegistry.refreshBackgroundSessionStatuses
isTerminalBackgroundSession ??= bgRegistry.isTerminalBackgroundSession
}
const sessions = await refreshBackgroundSessionStatuses()
for (const session of sessions) {
if (!isTerminalBackgroundSession(session)) {
skip.add(session.sessionId)
}
}
} catch {
// Registry unavailable or unreadable — fall back to UDS-only results.
}
return skip
}
/**
* Internal 3-way result from detection, before transforming interrupted_turn
* into interrupted_prompt with a synthetic continuation message.
@@ -606,19 +690,7 @@ export async function loadConversationForResume(
const logsPromise = loadMessageLogs()
let skip = new Set<string>()
if (feature('BG_SESSIONS')) {
try {
const { listAllLiveSessions } = await import('./udsClient.js')
const live = await listAllLiveSessions()
skip = new Set(
live.flatMap(s =>
s.kind && s.kind !== 'interactive' && s.sessionId
? [s.sessionId]
: [],
),
)
} catch {
// UDS unavailable — treat all sessions as continuable
}
skip = await collectLiveBackgroundSessionIds()
}
const logs = await logsPromise
log =
@@ -719,3 +791,19 @@ export async function loadConversationForResume(
throw error
}
}
export async function loadConversationForResumeFromPr(
selector: true | string,
): ReturnType<typeof loadConversationForResume> {
const log = findResumeLogByPrSelector(await loadMessageLogs(), selector)
if (!log) return null
return loadConversationForResume(log, undefined)
}
export async function findResumeSessionIdByPrSelector(
selector: true | string,
): Promise<UUID | null> {
const log = findResumeLogByPrSelector(await loadMessageLogs(), selector)
if (!log) return null
return getSessionIdFromLog(log) ?? null
}