mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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])}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user