concurrentSessions.ts
1 import { feature } from 'bun:bundle' 2 import { chmod, mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 3 import { join } from 'path' 4 import { 5 getOriginalCwd, 6 getSessionId, 7 onSessionSwitch, 8 } from '../bootstrap/state.js' 9 import { registerCleanup } from './cleanupRegistry.js' 10 import { logForDebugging } from './debug.js' 11 import { getClaudeConfigHomeDir } from './envUtils.js' 12 import { errorMessage, isFsInaccessible } from './errors.js' 13 import { isProcessRunning } from './genericProcessUtils.js' 14 import { getPlatform } from './platform.js' 15 import { jsonParse, jsonStringify } from './slowOperations.js' 16 import { getAgentId } from './teammate.js' 17 18 export type SessionKind = 'interactive' | 'bg' | 'daemon' | 'daemon-worker' 19 export type SessionStatus = 'busy' | 'idle' | 'waiting' 20 21 function 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 */ 31 function 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 */ 44 export 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 */ 59 export 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 */ 116 async 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 131 export 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 */ 144 export 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 */ 155 export 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 */ 168 export 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 }