@orgloop/agentctl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/adapters/claude-code.d.ts +83 -0
- package/dist/adapters/claude-code.js +783 -0
- package/dist/adapters/openclaw.d.ts +88 -0
- package/dist/adapters/openclaw.js +297 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +808 -0
- package/dist/client/daemon-client.d.ts +6 -0
- package/dist/client/daemon-client.js +81 -0
- package/dist/compat-shim.d.ts +2 -0
- package/dist/compat-shim.js +15 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +2 -0
- package/dist/daemon/fuse-engine.d.ts +30 -0
- package/dist/daemon/fuse-engine.js +118 -0
- package/dist/daemon/launchagent.d.ts +7 -0
- package/dist/daemon/launchagent.js +49 -0
- package/dist/daemon/lock-manager.d.ts +16 -0
- package/dist/daemon/lock-manager.js +71 -0
- package/dist/daemon/metrics.d.ts +20 -0
- package/dist/daemon/metrics.js +72 -0
- package/dist/daemon/server.d.ts +33 -0
- package/dist/daemon/server.js +283 -0
- package/dist/daemon/session-tracker.d.ts +28 -0
- package/dist/daemon/session-tracker.js +121 -0
- package/dist/daemon/state.d.ts +61 -0
- package/dist/daemon/state.js +126 -0
- package/dist/daemon/supervisor.d.ts +24 -0
- package/dist/daemon/supervisor.js +79 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +39 -0
- package/dist/merge.d.ts +24 -0
- package/dist/merge.js +65 -0
- package/dist/migration/migrate-locks.d.ts +5 -0
- package/dist/migration/migrate-locks.js +41 -0
- package/dist/worktree.d.ts +24 -0
- package/dist/worktree.js +65 -0
- package/package.json +60 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { watch } from "node:fs";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
10
|
+
// Default: only show stopped sessions from the last 7 days
|
|
11
|
+
const STOPPED_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
/**
|
|
13
|
+
* Claude Code adapter — reads session data directly from ~/.claude/
|
|
14
|
+
* and cross-references with running PIDs. NEVER maintains its own registry.
|
|
15
|
+
*/
|
|
16
|
+
export class ClaudeCodeAdapter {
|
|
17
|
+
id = "claude-code";
|
|
18
|
+
claudeDir;
|
|
19
|
+
projectsDir;
|
|
20
|
+
sessionsMetaDir;
|
|
21
|
+
getPids;
|
|
22
|
+
isProcessAlive;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.claudeDir = opts?.claudeDir || DEFAULT_CLAUDE_DIR;
|
|
25
|
+
this.projectsDir = path.join(this.claudeDir, "projects");
|
|
26
|
+
this.sessionsMetaDir =
|
|
27
|
+
opts?.sessionsMetaDir ||
|
|
28
|
+
path.join(this.claudeDir, "agentctl", "sessions");
|
|
29
|
+
this.getPids = opts?.getPids || getClaudePids;
|
|
30
|
+
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
31
|
+
}
|
|
32
|
+
async list(opts) {
|
|
33
|
+
const runningPids = await this.getPids();
|
|
34
|
+
const sessions = [];
|
|
35
|
+
let projectDirs;
|
|
36
|
+
try {
|
|
37
|
+
projectDirs = await fs.readdir(this.projectsDir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
for (const projDir of projectDirs) {
|
|
43
|
+
const projPath = path.join(this.projectsDir, projDir);
|
|
44
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
45
|
+
if (!stat?.isDirectory())
|
|
46
|
+
continue;
|
|
47
|
+
const entries = await this.getEntriesForProject(projPath, projDir);
|
|
48
|
+
for (const { entry, index } of entries) {
|
|
49
|
+
if (entry.isSidechain)
|
|
50
|
+
continue;
|
|
51
|
+
const session = await this.buildSessionFromIndex(entry, index, runningPids);
|
|
52
|
+
// Filter by status
|
|
53
|
+
if (opts?.status && session.status !== opts.status)
|
|
54
|
+
continue;
|
|
55
|
+
// If not --all, skip old stopped sessions
|
|
56
|
+
if (!opts?.all && session.status === "stopped") {
|
|
57
|
+
const age = Date.now() - session.startedAt.getTime();
|
|
58
|
+
if (age > STOPPED_SESSION_MAX_AGE_MS)
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Default: only show running sessions unless --all
|
|
62
|
+
if (!opts?.all &&
|
|
63
|
+
!opts?.status &&
|
|
64
|
+
session.status !== "running" &&
|
|
65
|
+
session.status !== "idle") {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
sessions.push(session);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Sort: running first, then by most recent
|
|
72
|
+
sessions.sort((a, b) => {
|
|
73
|
+
if (a.status === "running" && b.status !== "running")
|
|
74
|
+
return -1;
|
|
75
|
+
if (b.status === "running" && a.status !== "running")
|
|
76
|
+
return 1;
|
|
77
|
+
return b.startedAt.getTime() - a.startedAt.getTime();
|
|
78
|
+
});
|
|
79
|
+
return sessions;
|
|
80
|
+
}
|
|
81
|
+
async peek(sessionId, opts) {
|
|
82
|
+
const lines = opts?.lines ?? 20;
|
|
83
|
+
const jsonlPath = await this.findSessionFile(sessionId);
|
|
84
|
+
if (!jsonlPath)
|
|
85
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
86
|
+
const content = await fs.readFile(jsonlPath, "utf-8");
|
|
87
|
+
const jsonlLines = content.trim().split("\n");
|
|
88
|
+
const assistantMessages = [];
|
|
89
|
+
for (const line of jsonlLines) {
|
|
90
|
+
try {
|
|
91
|
+
const msg = JSON.parse(line);
|
|
92
|
+
if (msg.type === "assistant" && msg.message?.content) {
|
|
93
|
+
const text = extractTextContent(msg.message.content);
|
|
94
|
+
if (text)
|
|
95
|
+
assistantMessages.push(text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// skip malformed lines
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Take last N messages
|
|
103
|
+
const recent = assistantMessages.slice(-lines);
|
|
104
|
+
return recent.join("\n---\n");
|
|
105
|
+
}
|
|
106
|
+
async status(sessionId) {
|
|
107
|
+
const runningPids = await this.getPids();
|
|
108
|
+
const entry = await this.findIndexEntry(sessionId);
|
|
109
|
+
if (!entry)
|
|
110
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
111
|
+
return this.buildSessionFromIndex(entry.entry, entry.index, runningPids);
|
|
112
|
+
}
|
|
113
|
+
async launch(opts) {
|
|
114
|
+
const args = [
|
|
115
|
+
"--dangerously-skip-permissions",
|
|
116
|
+
"--print",
|
|
117
|
+
"--verbose",
|
|
118
|
+
"--output-format",
|
|
119
|
+
"stream-json",
|
|
120
|
+
];
|
|
121
|
+
if (opts.model) {
|
|
122
|
+
args.push("--model", opts.model);
|
|
123
|
+
}
|
|
124
|
+
args.push("-p", opts.prompt);
|
|
125
|
+
const env = { ...process.env, ...opts.env };
|
|
126
|
+
const cwd = opts.cwd || process.cwd();
|
|
127
|
+
// Write stdout to a log file so we can extract the session ID
|
|
128
|
+
// without keeping a pipe open (which would prevent full detachment).
|
|
129
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
130
|
+
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
|
|
131
|
+
const logFd = await fs.open(logPath, "w");
|
|
132
|
+
const child = spawn("claude", args, {
|
|
133
|
+
cwd,
|
|
134
|
+
env,
|
|
135
|
+
stdio: ["ignore", logFd.fd, "ignore"],
|
|
136
|
+
detached: true,
|
|
137
|
+
});
|
|
138
|
+
// Fully detach: child runs in its own process group.
|
|
139
|
+
// When the wrapper gets SIGTERM, the child keeps running.
|
|
140
|
+
child.unref();
|
|
141
|
+
const pid = child.pid;
|
|
142
|
+
const now = new Date();
|
|
143
|
+
// Close our handle — child keeps its own fd open
|
|
144
|
+
await logFd.close();
|
|
145
|
+
// Try to extract the real Claude Code session ID from the log output.
|
|
146
|
+
// Claude Code's stream-json format emits a line with sessionId early on.
|
|
147
|
+
let resolvedSessionId;
|
|
148
|
+
if (pid) {
|
|
149
|
+
resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
|
|
150
|
+
}
|
|
151
|
+
const sessionId = resolvedSessionId || crypto.randomUUID();
|
|
152
|
+
// Persist session metadata so status checks work after wrapper exits
|
|
153
|
+
if (pid) {
|
|
154
|
+
await this.writeSessionMeta({
|
|
155
|
+
sessionId,
|
|
156
|
+
pid,
|
|
157
|
+
wrapperPid: process.pid,
|
|
158
|
+
cwd,
|
|
159
|
+
model: opts.model,
|
|
160
|
+
prompt: opts.prompt.slice(0, 200),
|
|
161
|
+
launchedAt: now.toISOString(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const session = {
|
|
165
|
+
id: sessionId,
|
|
166
|
+
adapter: this.id,
|
|
167
|
+
status: "running",
|
|
168
|
+
startedAt: now,
|
|
169
|
+
cwd,
|
|
170
|
+
model: opts.model,
|
|
171
|
+
prompt: opts.prompt.slice(0, 200),
|
|
172
|
+
pid,
|
|
173
|
+
meta: {
|
|
174
|
+
adapterOpts: opts.adapterOpts,
|
|
175
|
+
spec: opts.spec,
|
|
176
|
+
logPath,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Poll the launch log file for up to `timeoutMs` to extract the real session ID.
|
|
183
|
+
* Claude Code's stream-json output includes sessionId in early messages.
|
|
184
|
+
*/
|
|
185
|
+
async pollForSessionId(logPath, pid, timeoutMs) {
|
|
186
|
+
const deadline = Date.now() + timeoutMs;
|
|
187
|
+
while (Date.now() < deadline) {
|
|
188
|
+
try {
|
|
189
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
190
|
+
for (const line of content.split("\n")) {
|
|
191
|
+
if (!line.trim())
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
const msg = JSON.parse(line);
|
|
195
|
+
if (msg.sessionId && typeof msg.sessionId === "string") {
|
|
196
|
+
return msg.sessionId;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Not valid JSON yet
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// File may not exist yet
|
|
206
|
+
}
|
|
207
|
+
// Check if process is still alive
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, 0);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
break; // Process died
|
|
213
|
+
}
|
|
214
|
+
await sleep(200);
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
async stop(sessionId, opts) {
|
|
219
|
+
const pid = await this.findPidForSession(sessionId);
|
|
220
|
+
if (!pid)
|
|
221
|
+
throw new Error(`No running process for session: ${sessionId}`);
|
|
222
|
+
if (opts?.force) {
|
|
223
|
+
// SIGINT first, then SIGKILL after 5s
|
|
224
|
+
process.kill(pid, "SIGINT");
|
|
225
|
+
await sleep(5000);
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, "SIGKILL");
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Already dead — good
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
process.kill(pid, "SIGTERM");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async resume(sessionId, message) {
|
|
238
|
+
const args = [
|
|
239
|
+
"--dangerously-skip-permissions",
|
|
240
|
+
"--print",
|
|
241
|
+
"--verbose",
|
|
242
|
+
"--output-format",
|
|
243
|
+
"stream-json",
|
|
244
|
+
"--continue",
|
|
245
|
+
sessionId,
|
|
246
|
+
"-p",
|
|
247
|
+
message,
|
|
248
|
+
];
|
|
249
|
+
const session = await this.status(sessionId).catch(() => null);
|
|
250
|
+
const cwd = session?.cwd || process.cwd();
|
|
251
|
+
const child = spawn("claude", args, {
|
|
252
|
+
cwd,
|
|
253
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
254
|
+
detached: true,
|
|
255
|
+
});
|
|
256
|
+
child.unref();
|
|
257
|
+
}
|
|
258
|
+
async *events() {
|
|
259
|
+
// Track known sessions to detect transitions
|
|
260
|
+
let knownSessions = new Map();
|
|
261
|
+
// Initial snapshot
|
|
262
|
+
const initial = await this.list({ all: true });
|
|
263
|
+
for (const s of initial) {
|
|
264
|
+
knownSessions.set(s.id, s);
|
|
265
|
+
}
|
|
266
|
+
// Poll + fs.watch hybrid
|
|
267
|
+
const watcher = watch(this.projectsDir, { recursive: true });
|
|
268
|
+
try {
|
|
269
|
+
while (true) {
|
|
270
|
+
await sleep(5000);
|
|
271
|
+
const current = await this.list({ all: true });
|
|
272
|
+
const currentMap = new Map(current.map((s) => [s.id, s]));
|
|
273
|
+
// Detect new sessions
|
|
274
|
+
for (const [id, session] of currentMap) {
|
|
275
|
+
const prev = knownSessions.get(id);
|
|
276
|
+
if (!prev) {
|
|
277
|
+
yield {
|
|
278
|
+
type: "session.started",
|
|
279
|
+
adapter: this.id,
|
|
280
|
+
sessionId: id,
|
|
281
|
+
session,
|
|
282
|
+
timestamp: new Date(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
else if (prev.status === "running" &&
|
|
286
|
+
session.status === "stopped") {
|
|
287
|
+
yield {
|
|
288
|
+
type: "session.stopped",
|
|
289
|
+
adapter: this.id,
|
|
290
|
+
sessionId: id,
|
|
291
|
+
session,
|
|
292
|
+
timestamp: new Date(),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
else if (prev.status === "running" && session.status === "idle") {
|
|
296
|
+
yield {
|
|
297
|
+
type: "session.idle",
|
|
298
|
+
adapter: this.id,
|
|
299
|
+
sessionId: id,
|
|
300
|
+
session,
|
|
301
|
+
timestamp: new Date(),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
knownSessions = currentMap;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
watcher.close();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// --- Private helpers ---
|
|
313
|
+
/**
|
|
314
|
+
* Get session entries for a project — uses sessions-index.json when available,
|
|
315
|
+
* falls back to scanning .jsonl files for projects without an index
|
|
316
|
+
* (e.g. currently running sessions that haven't been indexed yet).
|
|
317
|
+
*/
|
|
318
|
+
async getEntriesForProject(projPath, _projDirName) {
|
|
319
|
+
// Try index first
|
|
320
|
+
const indexPath = path.join(projPath, "sessions-index.json");
|
|
321
|
+
try {
|
|
322
|
+
const raw = await fs.readFile(indexPath, "utf-8");
|
|
323
|
+
const index = JSON.parse(raw);
|
|
324
|
+
return index.entries.map((entry) => ({ entry, index }));
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// No index — fall back to scanning .jsonl files
|
|
328
|
+
}
|
|
329
|
+
// We'll determine originalPath from the JSONL content below
|
|
330
|
+
let originalPath;
|
|
331
|
+
const results = [];
|
|
332
|
+
let files;
|
|
333
|
+
try {
|
|
334
|
+
files = await fs.readdir(projPath);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
for (const file of files) {
|
|
340
|
+
if (!file.endsWith(".jsonl"))
|
|
341
|
+
continue;
|
|
342
|
+
const sessionId = file.replace(".jsonl", "");
|
|
343
|
+
const fullPath = path.join(projPath, file);
|
|
344
|
+
let fileStat;
|
|
345
|
+
try {
|
|
346
|
+
fileStat = await fs.stat(fullPath);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Read first few lines for prompt and cwd
|
|
352
|
+
let firstPrompt = "";
|
|
353
|
+
let sessionCwd = "";
|
|
354
|
+
try {
|
|
355
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
356
|
+
for (const l of content.split("\n").slice(0, 20)) {
|
|
357
|
+
try {
|
|
358
|
+
const msg = JSON.parse(l);
|
|
359
|
+
if (msg.cwd && !sessionCwd)
|
|
360
|
+
sessionCwd = msg.cwd;
|
|
361
|
+
if (msg.type === "user" && msg.message?.content && !firstPrompt) {
|
|
362
|
+
const c = msg.message.content;
|
|
363
|
+
firstPrompt = typeof c === "string" ? c : "";
|
|
364
|
+
}
|
|
365
|
+
if (sessionCwd && firstPrompt)
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// skip
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// skip
|
|
375
|
+
}
|
|
376
|
+
if (!originalPath && sessionCwd) {
|
|
377
|
+
originalPath = sessionCwd;
|
|
378
|
+
}
|
|
379
|
+
const entryPath = sessionCwd || originalPath;
|
|
380
|
+
const index = {
|
|
381
|
+
version: 1,
|
|
382
|
+
entries: [],
|
|
383
|
+
originalPath: entryPath,
|
|
384
|
+
};
|
|
385
|
+
const entry = {
|
|
386
|
+
sessionId,
|
|
387
|
+
fullPath,
|
|
388
|
+
fileMtime: fileStat.mtimeMs,
|
|
389
|
+
firstPrompt,
|
|
390
|
+
created: fileStat.birthtime.toISOString(),
|
|
391
|
+
modified: fileStat.mtime.toISOString(),
|
|
392
|
+
projectPath: entryPath,
|
|
393
|
+
isSidechain: false,
|
|
394
|
+
};
|
|
395
|
+
results.push({ entry, index });
|
|
396
|
+
}
|
|
397
|
+
return results;
|
|
398
|
+
}
|
|
399
|
+
async buildSessionFromIndex(entry, index, runningPids) {
|
|
400
|
+
const isRunning = await this.isSessionRunning(entry, index, runningPids);
|
|
401
|
+
// Parse JSONL for token/model info (read last few lines for efficiency)
|
|
402
|
+
const { model, tokens } = await this.parseSessionTail(entry.fullPath);
|
|
403
|
+
return {
|
|
404
|
+
id: entry.sessionId,
|
|
405
|
+
adapter: this.id,
|
|
406
|
+
status: isRunning ? "running" : "stopped",
|
|
407
|
+
startedAt: new Date(entry.created),
|
|
408
|
+
stoppedAt: isRunning ? undefined : new Date(entry.modified),
|
|
409
|
+
cwd: index.originalPath || entry.projectPath,
|
|
410
|
+
model,
|
|
411
|
+
prompt: entry.firstPrompt?.slice(0, 200),
|
|
412
|
+
tokens,
|
|
413
|
+
pid: isRunning
|
|
414
|
+
? await this.findMatchingPid(entry, index, runningPids)
|
|
415
|
+
: undefined,
|
|
416
|
+
meta: {
|
|
417
|
+
projectDir: index.originalPath || entry.projectPath,
|
|
418
|
+
gitBranch: entry.gitBranch,
|
|
419
|
+
messageCount: entry.messageCount,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
async isSessionRunning(entry, index, runningPids) {
|
|
424
|
+
const projectPath = index.originalPath || entry.projectPath;
|
|
425
|
+
if (!projectPath)
|
|
426
|
+
return false;
|
|
427
|
+
const sessionCreated = new Date(entry.created).getTime();
|
|
428
|
+
// 1. Check running PIDs discovered via `ps aux`
|
|
429
|
+
for (const [, info] of runningPids) {
|
|
430
|
+
// Check if the session ID appears in the command args — most reliable match
|
|
431
|
+
if (info.args.includes(entry.sessionId)) {
|
|
432
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
433
|
+
return true;
|
|
434
|
+
// PID recycling: process started before this session existed
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
// Match by cwd — less specific (multiple sessions share a project)
|
|
438
|
+
if (info.cwd === projectPath) {
|
|
439
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// 2. Check persisted session metadata (for detached processes that
|
|
444
|
+
// may not appear in `ps aux` filtering, e.g. after wrapper exit)
|
|
445
|
+
const meta = await this.readSessionMeta(entry.sessionId);
|
|
446
|
+
if (meta?.pid) {
|
|
447
|
+
// Verify the persisted PID is still alive
|
|
448
|
+
if (this.isProcessAlive(meta.pid)) {
|
|
449
|
+
// Cross-check: if this PID appears in runningPids with a DIFFERENT
|
|
450
|
+
// start time than what we recorded, the PID was recycled.
|
|
451
|
+
const pidInfo = runningPids.get(meta.pid);
|
|
452
|
+
if (pidInfo?.startTime && meta.startTime) {
|
|
453
|
+
const currentStartMs = new Date(pidInfo.startTime).getTime();
|
|
454
|
+
const recordedStartMs = new Date(meta.startTime).getTime();
|
|
455
|
+
if (!Number.isNaN(currentStartMs) &&
|
|
456
|
+
!Number.isNaN(recordedStartMs) &&
|
|
457
|
+
Math.abs(currentStartMs - recordedStartMs) > 5000) {
|
|
458
|
+
// Process at this PID has a different start time — recycled
|
|
459
|
+
await this.deleteSessionMeta(entry.sessionId);
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Verify stored start time is consistent with launch time
|
|
464
|
+
if (meta.startTime) {
|
|
465
|
+
const metaStartMs = new Date(meta.startTime).getTime();
|
|
466
|
+
const sessionMs = new Date(meta.launchedAt).getTime();
|
|
467
|
+
if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
// Start time doesn't match — PID was recycled, clean up stale metadata
|
|
471
|
+
await this.deleteSessionMeta(entry.sessionId);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
// No start time in metadata — can't verify, assume alive
|
|
475
|
+
// (only for sessions launched with the new detached model)
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
// PID is dead — clean up stale metadata
|
|
479
|
+
await this.deleteSessionMeta(entry.sessionId);
|
|
480
|
+
}
|
|
481
|
+
// 3. Fallback: check if JSONL was modified very recently (last 60s)
|
|
482
|
+
try {
|
|
483
|
+
const stat = await fs.stat(entry.fullPath);
|
|
484
|
+
const age = Date.now() - stat.mtimeMs;
|
|
485
|
+
if (age < 60_000) {
|
|
486
|
+
// Double-check: is there any claude process running with matching cwd
|
|
487
|
+
// that started after this session?
|
|
488
|
+
for (const [, info] of runningPids) {
|
|
489
|
+
if (info.cwd === projectPath &&
|
|
490
|
+
this.processStartedAfterSession(info, sessionCreated)) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// file doesn't exist
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Check whether a process plausibly belongs to a session by verifying
|
|
503
|
+
* the process started at or after the session's creation time.
|
|
504
|
+
* This detects PID recycling: if a process started before the session
|
|
505
|
+
* was created, it can't be the process that's running this session.
|
|
506
|
+
*
|
|
507
|
+
* When start time is unavailable, defaults to false (assume no match).
|
|
508
|
+
* This prevents old sessions from appearing as 'running' due to
|
|
509
|
+
* recycled PIDs when start time verification is impossible.
|
|
510
|
+
*/
|
|
511
|
+
processStartedAfterSession(info, sessionCreatedMs) {
|
|
512
|
+
if (!info.startTime)
|
|
513
|
+
return false; // Can't verify — assume no match (safety)
|
|
514
|
+
const processStartMs = new Date(info.startTime).getTime();
|
|
515
|
+
if (Number.isNaN(processStartMs))
|
|
516
|
+
return false; // Unparseable — assume no match
|
|
517
|
+
// Allow 5s tolerance for clock skew between session creation time and ps output
|
|
518
|
+
return processStartMs >= sessionCreatedMs - 5000;
|
|
519
|
+
}
|
|
520
|
+
async findMatchingPid(entry, index, runningPids) {
|
|
521
|
+
const projectPath = index.originalPath || entry.projectPath;
|
|
522
|
+
const sessionCreated = new Date(entry.created).getTime();
|
|
523
|
+
for (const [pid, info] of runningPids) {
|
|
524
|
+
if (info.args.includes(entry.sessionId)) {
|
|
525
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
526
|
+
return pid;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (info.cwd === projectPath) {
|
|
530
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
531
|
+
return pid;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Check persisted metadata for detached processes
|
|
535
|
+
const meta = await this.readSessionMeta(entry.sessionId);
|
|
536
|
+
if (meta?.pid && this.isProcessAlive(meta.pid)) {
|
|
537
|
+
return meta.pid;
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
async parseSessionTail(jsonlPath) {
|
|
542
|
+
try {
|
|
543
|
+
const content = await fs.readFile(jsonlPath, "utf-8");
|
|
544
|
+
const lines = content.trim().split("\n");
|
|
545
|
+
let model;
|
|
546
|
+
let totalIn = 0;
|
|
547
|
+
let totalOut = 0;
|
|
548
|
+
// Read from the end for efficiency — last 100 lines
|
|
549
|
+
const tail = lines.slice(-100);
|
|
550
|
+
for (const line of tail) {
|
|
551
|
+
try {
|
|
552
|
+
const msg = JSON.parse(line);
|
|
553
|
+
if (msg.type === "assistant" && msg.message) {
|
|
554
|
+
if (msg.message.model)
|
|
555
|
+
model = msg.message.model;
|
|
556
|
+
if (msg.message.usage) {
|
|
557
|
+
totalIn += msg.message.usage.input_tokens || 0;
|
|
558
|
+
totalOut += msg.message.usage.output_tokens || 0;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// skip
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Also scan first few lines for model if we didn't find it
|
|
567
|
+
if (!model) {
|
|
568
|
+
const head = lines.slice(0, 20);
|
|
569
|
+
for (const line of head) {
|
|
570
|
+
try {
|
|
571
|
+
const msg = JSON.parse(line);
|
|
572
|
+
if (msg.type === "assistant" && msg.message?.model) {
|
|
573
|
+
model = msg.message.model;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// skip
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
model,
|
|
584
|
+
tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return {};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async findSessionFile(sessionId) {
|
|
592
|
+
const entry = await this.findIndexEntry(sessionId);
|
|
593
|
+
if (!entry)
|
|
594
|
+
return null;
|
|
595
|
+
try {
|
|
596
|
+
await fs.access(entry.entry.fullPath);
|
|
597
|
+
return entry.entry.fullPath;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async findIndexEntry(sessionId) {
|
|
604
|
+
let projectDirs;
|
|
605
|
+
try {
|
|
606
|
+
projectDirs = await fs.readdir(this.projectsDir);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
for (const projDir of projectDirs) {
|
|
612
|
+
const projPath = path.join(this.projectsDir, projDir);
|
|
613
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
614
|
+
if (!stat?.isDirectory())
|
|
615
|
+
continue;
|
|
616
|
+
const entries = await this.getEntriesForProject(projPath, projDir);
|
|
617
|
+
// Support prefix matching for short IDs
|
|
618
|
+
const match = entries.find(({ entry: e }) => e.sessionId === sessionId || e.sessionId.startsWith(sessionId));
|
|
619
|
+
if (match)
|
|
620
|
+
return match;
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
async findPidForSession(sessionId) {
|
|
625
|
+
const session = await this.status(sessionId);
|
|
626
|
+
return session.pid ?? null;
|
|
627
|
+
}
|
|
628
|
+
// --- Session metadata persistence ---
|
|
629
|
+
/** Write session metadata to disk so status checks survive wrapper exit */
|
|
630
|
+
async writeSessionMeta(meta) {
|
|
631
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
632
|
+
// Try to capture the process start time immediately
|
|
633
|
+
let startTime;
|
|
634
|
+
try {
|
|
635
|
+
const { stdout } = await execFileAsync("ps", [
|
|
636
|
+
"-p",
|
|
637
|
+
meta.pid.toString(),
|
|
638
|
+
"-o",
|
|
639
|
+
"lstart=",
|
|
640
|
+
]);
|
|
641
|
+
startTime = stdout.trim() || undefined;
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
// Process may have already exited or ps failed
|
|
645
|
+
}
|
|
646
|
+
const fullMeta = { ...meta, startTime };
|
|
647
|
+
const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
|
|
648
|
+
await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
|
|
649
|
+
}
|
|
650
|
+
/** Read persisted session metadata */
|
|
651
|
+
async readSessionMeta(sessionId) {
|
|
652
|
+
// Check exact sessionId first
|
|
653
|
+
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
|
|
654
|
+
try {
|
|
655
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
656
|
+
return JSON.parse(raw);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// File doesn't exist or is unreadable
|
|
660
|
+
}
|
|
661
|
+
// Scan all metadata files for one whose sessionId matches
|
|
662
|
+
// (handles resolved session IDs that were originally pending-*)
|
|
663
|
+
try {
|
|
664
|
+
const files = await fs.readdir(this.sessionsMetaDir);
|
|
665
|
+
for (const file of files) {
|
|
666
|
+
if (!file.endsWith(".json"))
|
|
667
|
+
continue;
|
|
668
|
+
try {
|
|
669
|
+
const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
|
|
670
|
+
const meta = JSON.parse(raw);
|
|
671
|
+
if (meta.sessionId === sessionId)
|
|
672
|
+
return meta;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
// skip
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
// Dir doesn't exist
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
/** Delete stale session metadata */
|
|
685
|
+
async deleteSessionMeta(sessionId) {
|
|
686
|
+
for (const id of [sessionId, `pending-${sessionId}`]) {
|
|
687
|
+
const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
|
|
688
|
+
try {
|
|
689
|
+
await fs.unlink(metaPath);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// File doesn't exist
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// --- Utility functions ---
|
|
698
|
+
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
699
|
+
function defaultIsProcessAlive(pid) {
|
|
700
|
+
try {
|
|
701
|
+
process.kill(pid, 0);
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async function getClaudePids() {
|
|
709
|
+
const pids = new Map();
|
|
710
|
+
try {
|
|
711
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
712
|
+
for (const line of stdout.split("\n")) {
|
|
713
|
+
if (!line.includes("claude") || line.includes("grep"))
|
|
714
|
+
continue;
|
|
715
|
+
// Extract PID (second field) and command (everything after 10th field)
|
|
716
|
+
const fields = line.trim().split(/\s+/);
|
|
717
|
+
if (fields.length < 11)
|
|
718
|
+
continue;
|
|
719
|
+
const pid = parseInt(fields[1], 10);
|
|
720
|
+
const command = fields.slice(10).join(" ");
|
|
721
|
+
// Only match lines where the command starts with "claude --"
|
|
722
|
+
// This excludes wrappers (tclsh, bash, screen, login) and
|
|
723
|
+
// interactive claude sessions (just "claude" with no flags)
|
|
724
|
+
if (!command.startsWith("claude --"))
|
|
725
|
+
continue;
|
|
726
|
+
if (pid === process.pid)
|
|
727
|
+
continue;
|
|
728
|
+
// Try to extract working directory from lsof
|
|
729
|
+
let cwd = "";
|
|
730
|
+
try {
|
|
731
|
+
const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
|
|
732
|
+
"-p",
|
|
733
|
+
pid.toString(),
|
|
734
|
+
"-Fn",
|
|
735
|
+
]);
|
|
736
|
+
// lsof output: "fcwd\nn/actual/path\n..." — find fcwd line, then next n line
|
|
737
|
+
const lsofLines = lsofOut.split("\n");
|
|
738
|
+
for (let i = 0; i < lsofLines.length; i++) {
|
|
739
|
+
if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
|
|
740
|
+
cwd = lsofLines[i + 1].slice(1); // strip leading "n"
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// lsof might fail — that's fine
|
|
747
|
+
}
|
|
748
|
+
// Get process start time for PID recycling detection
|
|
749
|
+
let startTime;
|
|
750
|
+
try {
|
|
751
|
+
const { stdout: lstart } = await execFileAsync("ps", [
|
|
752
|
+
"-p",
|
|
753
|
+
pid.toString(),
|
|
754
|
+
"-o",
|
|
755
|
+
"lstart=",
|
|
756
|
+
]);
|
|
757
|
+
startTime = lstart.trim() || undefined;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
// ps might fail — that's fine
|
|
761
|
+
}
|
|
762
|
+
pids.set(pid, { pid, cwd, args: command, startTime });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// ps failed — return empty
|
|
767
|
+
}
|
|
768
|
+
return pids;
|
|
769
|
+
}
|
|
770
|
+
function extractTextContent(content) {
|
|
771
|
+
if (typeof content === "string")
|
|
772
|
+
return content;
|
|
773
|
+
if (Array.isArray(content)) {
|
|
774
|
+
return content
|
|
775
|
+
.filter((b) => b.type === "text" && b.text)
|
|
776
|
+
.map((b) => b.text)
|
|
777
|
+
.join("\n");
|
|
778
|
+
}
|
|
779
|
+
return "";
|
|
780
|
+
}
|
|
781
|
+
function sleep(ms) {
|
|
782
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
783
|
+
}
|