/ utils / concurrentSessions.ts
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  }