concurrentSessions.ts
utils/concurrentSessions.ts
205
Lines
6808
Bytes
8
Exports
12
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with session-engine. It contains 205 lines, 12 detected imports, and 8 detected exports.
Important relationships
Detected exports
SessionKindSessionStatusisBgSessionregisterSessionupdateSessionNameupdateSessionBridgeIdupdateSessionActivitycountConcurrentSessions
Keywords
fileprocesssessionclaudepromisepidfilenamevoidupdatepidfilefeature
Detected imports
bun:bundlefs/promisespath../bootstrap/state.js./cleanupRegistry.js./debug.js./envUtils.js./errors.js./genericProcessUtils.js./platform.js./slowOperations.js./teammate.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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<boolean> {
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<number> {
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 `<pid>.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
}