forked from sanbuphy/learn-coding-agent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconcurrentSessions.ts
More file actions
204 lines (190 loc) · 6.65 KB
/
concurrentSessions.ts
File metadata and controls
204 lines (190 loc) · 6.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
}