import { feature } from 'bun:bundle' import { chmod, mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' import { join } from 'path' import { getOriginalCwd, getSessionId, onSessionSwitch, } from '../bootstrap/state.js' import { registerCleanup } from './cleanupRegistry.js' import { logForDebugging } from './debug.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { errorMessage, isFsInaccessible } from './errors.js' import { isProcessRunning } from './genericProcessUtils.js' import { getPlatform } from './platform.js' import { jsonParse, jsonStringify } from './slowOperations.js' import { getAgentId } from './teammate.js' export type SessionKind = 'interactive' | 'bg' | 'daemon' | 'daemon-worker' export type SessionStatus = 'busy' | 'idle' | 'waiting' function getSessionsDir(): string { return join(getClaudeConfigHomeDir(), 'sessions') } /** * Kind override from env. Set by the spawner (`claude --bg`, daemon * supervisor) so the child can register without the parent having to * write the file for it — cleanup-on-exit wiring then works for free. * Gated so the env-var string is DCE'd from external builds. */ function envSessionKind(): SessionKind | undefined { if (feature('BG_SESSIONS')) { const k = process.env.CLAUDE_CODE_SESSION_KIND if (k === 'bg' || k === 'daemon' || k === 'daemon-worker') return k } return undefined } /** * True when this REPL is running inside a `claude --bg` tmux session. * Exit paths (/exit, ctrl+c, ctrl+d) should detach the attached client * instead of killing the process. */ export function isBgSession(): boolean { return envSessionKind() === 'bg' } /** * Write a PID file for this session and register cleanup. * * Registers all top-level sessions — interactive CLI, SDK (vscode, desktop, * typescript, python, -p), bg/daemon spawns — so `claude ps` sees everything * the user might be running. Skips only teammates/subagents, which would * conflate swarm usage with genuine concurrency and pollute ps with noise. * * Returns true if registered, false if skipped. * Errors logged to debug, never thrown. */ export async function registerSession(): Promise { if (getAgentId() != null) return false const kind: SessionKind = envSessionKind() ?? 'interactive' const dir = getSessionsDir() const pidFile = join(dir, `${process.pid}.json`) registerCleanup(async () => { try { await unlink(pidFile) } catch { // ENOENT is fine (already deleted or never written) } }) try { await mkdir(dir, { recursive: true, mode: 0o700 }) await chmod(dir, 0o700) await writeFile( pidFile, jsonStringify({ pid: process.pid, sessionId: getSessionId(), cwd: getOriginalCwd(), startedAt: Date.now(), kind, entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, ...(feature('UDS_INBOX') ? { messagingSocketPath: process.env.CLAUDE_CODE_MESSAGING_SOCKET } : {}), ...(feature('BG_SESSIONS') ? { name: process.env.CLAUDE_CODE_SESSION_NAME, logPath: process.env.CLAUDE_CODE_SESSION_LOG, agent: process.env.CLAUDE_CODE_AGENT, } : {}), }), ) // --resume / /resume mutates getSessionId() via switchSession. Without // this, the PID file's sessionId goes stale and `claude ps` sparkline // reads the wrong transcript. onSessionSwitch(id => { void updatePidFile({ sessionId: id }) }) return true } catch (e) { logForDebugging(`[concurrentSessions] register failed: ${errorMessage(e)}`) return false } } /** * Update this session's name in its PID registry file so ListPeers * can surface it. Best-effort: silently no-op if name is falsy, the * file doesn't exist (session not registered), or read/write fails. */ async function updatePidFile(patch: Record): Promise { const pidFile = join(getSessionsDir(), `${process.pid}.json`) try { const data = jsonParse(await readFile(pidFile, 'utf8')) as Record< string, unknown > await writeFile(pidFile, jsonStringify({ ...data, ...patch })) } catch (e) { logForDebugging( `[concurrentSessions] updatePidFile failed: ${errorMessage(e)}`, ) } } export async function updateSessionName( name: string | undefined, ): Promise { if (!name) return await updatePidFile({ name }) } /** * Record this session's Remote Control session ID so peer enumeration can * dedup: a session reachable over both UDS and bridge should only appear * once (local wins). Cleared on bridge teardown so stale IDs don't * suppress a legitimately-remote session after reconnect. */ export async function updateSessionBridgeId( bridgeSessionId: string | null, ): Promise { await updatePidFile({ bridgeSessionId }) } /** * Push live activity state for `claude ps`. Fire-and-forget from REPL's * status-change effect — a dropped write just means ps falls back to * transcript-tail derivation for one refresh. */ export async function updateSessionActivity(patch: { status?: SessionStatus waitingFor?: string }): Promise { if (!feature('BG_SESSIONS')) return await updatePidFile({ ...patch, updatedAt: Date.now() }) } /** * Count live concurrent CLI sessions (including this one). * Filters out stale PID files (crashed sessions) and deletes them. * Returns 0 on any error (conservative). */ export async function countConcurrentSessions(): Promise { const dir = getSessionsDir() let files: string[] try { files = await readdir(dir) } catch (e) { if (!isFsInaccessible(e)) { logForDebugging(`[concurrentSessions] readdir failed: ${errorMessage(e)}`) } return 0 } let count = 0 for (const file of files) { // Strict filename guard: only `.json` is a candidate. parseInt's // lenient prefix-parsing means `2026-03-14_notes.md` would otherwise // parse as PID 2026 and get swept as stale — silent user data loss. // See anthropics/claude-code#34210. if (!/^\d+\.json$/.test(file)) continue const pid = parseInt(file.slice(0, -5), 10) if (pid === process.pid) { count++ continue } if (isProcessRunning(pid)) { count++ } else if (getPlatform() !== 'wsl') { // Stale file from a crashed session — sweep it. Skip on WSL: if // ~/.claude/sessions/ is shared with Windows-native Claude (symlink // or CLAUDE_CONFIG_DIR), a Windows PID won't be probeable from WSL // and we'd falsely delete a live session's file. This is just // telemetry so conservative undercount is acceptable. void unlink(join(dir, file)).catch(() => {}) } } return count }