source dump of claude code
at main 204 lines 6.8 kB view raw
1import { feature } from 'bun:bundle' 2import { chmod, mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 3import { join } from 'path' 4import { 5 getOriginalCwd, 6 getSessionId, 7 onSessionSwitch, 8} from '../bootstrap/state.js' 9import { registerCleanup } from './cleanupRegistry.js' 10import { logForDebugging } from './debug.js' 11import { getClaudeConfigHomeDir } from './envUtils.js' 12import { errorMessage, isFsInaccessible } from './errors.js' 13import { isProcessRunning } from './genericProcessUtils.js' 14import { getPlatform } from './platform.js' 15import { jsonParse, jsonStringify } from './slowOperations.js' 16import { getAgentId } from './teammate.js' 17 18export type SessionKind = 'interactive' | 'bg' | 'daemon' | 'daemon-worker' 19export type SessionStatus = 'busy' | 'idle' | 'waiting' 20 21function getSessionsDir(): string { 22 return join(getClaudeConfigHomeDir(), 'sessions') 23} 24 25/** 26 * Kind override from env. Set by the spawner (`claude --bg`, daemon 27 * supervisor) so the child can register without the parent having to 28 * write the file for it — cleanup-on-exit wiring then works for free. 29 * Gated so the env-var string is DCE'd from external builds. 30 */ 31function envSessionKind(): SessionKind | undefined { 32 if (feature('BG_SESSIONS')) { 33 const k = process.env.CLAUDE_CODE_SESSION_KIND 34 if (k === 'bg' || k === 'daemon' || k === 'daemon-worker') return k 35 } 36 return undefined 37} 38 39/** 40 * True when this REPL is running inside a `claude --bg` tmux session. 41 * Exit paths (/exit, ctrl+c, ctrl+d) should detach the attached client 42 * instead of killing the process. 43 */ 44export function isBgSession(): boolean { 45 return envSessionKind() === 'bg' 46} 47 48/** 49 * Write a PID file for this session and register cleanup. 50 * 51 * Registers all top-level sessions — interactive CLI, SDK (vscode, desktop, 52 * typescript, python, -p), bg/daemon spawns — so `claude ps` sees everything 53 * the user might be running. Skips only teammates/subagents, which would 54 * conflate swarm usage with genuine concurrency and pollute ps with noise. 55 * 56 * Returns true if registered, false if skipped. 57 * Errors logged to debug, never thrown. 58 */ 59export async function registerSession(): Promise<boolean> { 60 if (getAgentId() != null) return false 61 62 const kind: SessionKind = envSessionKind() ?? 'interactive' 63 const dir = getSessionsDir() 64 const pidFile = join(dir, `${process.pid}.json`) 65 66 registerCleanup(async () => { 67 try { 68 await unlink(pidFile) 69 } catch { 70 // ENOENT is fine (already deleted or never written) 71 } 72 }) 73 74 try { 75 await mkdir(dir, { recursive: true, mode: 0o700 }) 76 await chmod(dir, 0o700) 77 await writeFile( 78 pidFile, 79 jsonStringify({ 80 pid: process.pid, 81 sessionId: getSessionId(), 82 cwd: getOriginalCwd(), 83 startedAt: Date.now(), 84 kind, 85 entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, 86 ...(feature('UDS_INBOX') 87 ? { messagingSocketPath: process.env.CLAUDE_CODE_MESSAGING_SOCKET } 88 : {}), 89 ...(feature('BG_SESSIONS') 90 ? { 91 name: process.env.CLAUDE_CODE_SESSION_NAME, 92 logPath: process.env.CLAUDE_CODE_SESSION_LOG, 93 agent: process.env.CLAUDE_CODE_AGENT, 94 } 95 : {}), 96 }), 97 ) 98 // --resume / /resume mutates getSessionId() via switchSession. Without 99 // this, the PID file's sessionId goes stale and `claude ps` sparkline 100 // reads the wrong transcript. 101 onSessionSwitch(id => { 102 void updatePidFile({ sessionId: id }) 103 }) 104 return true 105 } catch (e) { 106 logForDebugging(`[concurrentSessions] register failed: ${errorMessage(e)}`) 107 return false 108 } 109} 110 111/** 112 * Update this session's name in its PID registry file so ListPeers 113 * can surface it. Best-effort: silently no-op if name is falsy, the 114 * file doesn't exist (session not registered), or read/write fails. 115 */ 116async function updatePidFile(patch: Record<string, unknown>): Promise<void> { 117 const pidFile = join(getSessionsDir(), `${process.pid}.json`) 118 try { 119 const data = jsonParse(await readFile(pidFile, 'utf8')) as Record< 120 string, 121 unknown 122 > 123 await writeFile(pidFile, jsonStringify({ ...data, ...patch })) 124 } catch (e) { 125 logForDebugging( 126 `[concurrentSessions] updatePidFile failed: ${errorMessage(e)}`, 127 ) 128 } 129} 130 131export async function updateSessionName( 132 name: string | undefined, 133): Promise<void> { 134 if (!name) return 135 await updatePidFile({ name }) 136} 137 138/** 139 * Record this session's Remote Control session ID so peer enumeration can 140 * dedup: a session reachable over both UDS and bridge should only appear 141 * once (local wins). Cleared on bridge teardown so stale IDs don't 142 * suppress a legitimately-remote session after reconnect. 143 */ 144export async function updateSessionBridgeId( 145 bridgeSessionId: string | null, 146): Promise<void> { 147 await updatePidFile({ bridgeSessionId }) 148} 149 150/** 151 * Push live activity state for `claude ps`. Fire-and-forget from REPL's 152 * status-change effect — a dropped write just means ps falls back to 153 * transcript-tail derivation for one refresh. 154 */ 155export async function updateSessionActivity(patch: { 156 status?: SessionStatus 157 waitingFor?: string 158}): Promise<void> { 159 if (!feature('BG_SESSIONS')) return 160 await updatePidFile({ ...patch, updatedAt: Date.now() }) 161} 162 163/** 164 * Count live concurrent CLI sessions (including this one). 165 * Filters out stale PID files (crashed sessions) and deletes them. 166 * Returns 0 on any error (conservative). 167 */ 168export async function countConcurrentSessions(): Promise<number> { 169 const dir = getSessionsDir() 170 let files: string[] 171 try { 172 files = await readdir(dir) 173 } catch (e) { 174 if (!isFsInaccessible(e)) { 175 logForDebugging(`[concurrentSessions] readdir failed: ${errorMessage(e)}`) 176 } 177 return 0 178 } 179 180 let count = 0 181 for (const file of files) { 182 // Strict filename guard: only `<pid>.json` is a candidate. parseInt's 183 // lenient prefix-parsing means `2026-03-14_notes.md` would otherwise 184 // parse as PID 2026 and get swept as stale — silent user data loss. 185 // See anthropics/claude-code#34210. 186 if (!/^\d+\.json$/.test(file)) continue 187 const pid = parseInt(file.slice(0, -5), 10) 188 if (pid === process.pid) { 189 count++ 190 continue 191 } 192 if (isProcessRunning(pid)) { 193 count++ 194 } else if (getPlatform() !== 'wsl') { 195 // Stale file from a crashed session — sweep it. Skip on WSL: if 196 // ~/.claude/sessions/ is shared with Windows-native Claude (symlink 197 // or CLAUDE_CONFIG_DIR), a Windows PID won't be probeable from WSL 198 // and we'd falsely delete a live session's file. This is just 199 // telemetry so conservative undercount is acceptable. 200 void unlink(join(dir, file)).catch(() => {}) 201 } 202 } 203 return count 204}