@orgloop/agentctl 1.1.0 → 1.2.1
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/README.md +145 -3
- package/dist/adapters/claude-code.js +23 -12
- package/dist/adapters/codex.d.ts +72 -0
- package/dist/adapters/codex.js +702 -0
- package/dist/adapters/openclaw.d.ts +60 -9
- package/dist/adapters/openclaw.js +195 -38
- package/dist/adapters/opencode.d.ts +143 -0
- package/dist/adapters/opencode.js +682 -0
- package/dist/adapters/pi-rust.d.ts +89 -0
- package/dist/adapters/pi-rust.js +753 -0
- package/dist/adapters/pi.d.ts +96 -0
- package/dist/adapters/pi.js +865 -0
- package/dist/cli.js +332 -60
- package/dist/core/types.d.ts +1 -0
- package/dist/daemon/server.js +152 -21
- package/dist/daemon/session-tracker.d.ts +24 -0
- package/dist/daemon/session-tracker.js +149 -4
- package/dist/daemon/state.d.ts +1 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +4 -0
- package/dist/launch-orchestrator.d.ts +60 -0
- package/dist/launch-orchestrator.js +198 -0
- package/dist/matrix-parser.d.ts +40 -0
- package/dist/matrix-parser.js +69 -0
- package/dist/utils/daemon-env.d.ts +16 -0
- package/dist/utils/daemon-env.js +85 -0
- package/dist/utils/partial-read.d.ts +20 -0
- package/dist/utils/partial-read.js +66 -0
- package/dist/utils/resolve-binary.d.ts +14 -0
- package/dist/utils/resolve-binary.js +66 -0
- package/dist/worktree.d.ts +22 -0
- package/dist/worktree.js +68 -0
- package/package.json +3 -2
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { readFileSync, 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
|
+
import { buildSpawnEnv } from "../utils/daemon-env.js";
|
|
9
|
+
import { resolveBinaryPath } from "../utils/resolve-binary.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const DEFAULT_CODEX_DIR = path.join(os.homedir(), ".codex");
|
|
12
|
+
// Default: only show stopped sessions from the last 7 days
|
|
13
|
+
const STOPPED_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* Codex CLI adapter — reads session data from ~/.codex/sessions/
|
|
16
|
+
* and cross-references with running PIDs.
|
|
17
|
+
*/
|
|
18
|
+
export class CodexAdapter {
|
|
19
|
+
id = "codex";
|
|
20
|
+
codexDir;
|
|
21
|
+
sessionsDir;
|
|
22
|
+
sessionsMetaDir;
|
|
23
|
+
getPids;
|
|
24
|
+
isProcessAlive;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
this.codexDir = opts?.codexDir || DEFAULT_CODEX_DIR;
|
|
27
|
+
this.sessionsDir = path.join(this.codexDir, "sessions");
|
|
28
|
+
this.sessionsMetaDir =
|
|
29
|
+
opts?.sessionsMetaDir || path.join(this.codexDir, "agentctl", "sessions");
|
|
30
|
+
this.getPids = opts?.getPids || getCodexPids;
|
|
31
|
+
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
32
|
+
}
|
|
33
|
+
async list(opts) {
|
|
34
|
+
const runningPids = await this.getPids();
|
|
35
|
+
const sessionInfos = await this.discoverSessions();
|
|
36
|
+
const sessions = [];
|
|
37
|
+
for (const info of sessionInfos) {
|
|
38
|
+
const session = this.buildSession(info, runningPids);
|
|
39
|
+
if (opts?.status && session.status !== opts.status)
|
|
40
|
+
continue;
|
|
41
|
+
if (!opts?.all && session.status === "stopped") {
|
|
42
|
+
const age = Date.now() - session.startedAt.getTime();
|
|
43
|
+
if (age > STOPPED_SESSION_MAX_AGE_MS)
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!opts?.all &&
|
|
47
|
+
!opts?.status &&
|
|
48
|
+
session.status !== "running" &&
|
|
49
|
+
session.status !== "idle") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
sessions.push(session);
|
|
53
|
+
}
|
|
54
|
+
sessions.sort((a, b) => {
|
|
55
|
+
if (a.status === "running" && b.status !== "running")
|
|
56
|
+
return -1;
|
|
57
|
+
if (b.status === "running" && a.status !== "running")
|
|
58
|
+
return 1;
|
|
59
|
+
return b.startedAt.getTime() - a.startedAt.getTime();
|
|
60
|
+
});
|
|
61
|
+
return sessions;
|
|
62
|
+
}
|
|
63
|
+
async peek(sessionId, opts) {
|
|
64
|
+
const lines = opts?.lines ?? 20;
|
|
65
|
+
const info = await this.findSession(sessionId);
|
|
66
|
+
if (!info)
|
|
67
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
68
|
+
const content = await fs.readFile(info.filePath, "utf-8");
|
|
69
|
+
const jsonlLines = content.trim().split("\n");
|
|
70
|
+
const messages = [];
|
|
71
|
+
for (const line of jsonlLines) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line);
|
|
74
|
+
// Extract agent messages from event_msg type (primary source)
|
|
75
|
+
if (parsed.type === "event_msg" &&
|
|
76
|
+
parsed.payload?.type === "agent_message" &&
|
|
77
|
+
parsed.payload.message) {
|
|
78
|
+
messages.push(parsed.payload.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// skip malformed lines
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const recent = messages.slice(-lines);
|
|
86
|
+
return recent.join("\n---\n");
|
|
87
|
+
}
|
|
88
|
+
async status(sessionId) {
|
|
89
|
+
const runningPids = await this.getPids();
|
|
90
|
+
const info = await this.findSession(sessionId);
|
|
91
|
+
if (!info)
|
|
92
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
93
|
+
return this.buildSession(info, runningPids);
|
|
94
|
+
}
|
|
95
|
+
async launch(opts) {
|
|
96
|
+
const args = [
|
|
97
|
+
"exec",
|
|
98
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
99
|
+
"--json",
|
|
100
|
+
];
|
|
101
|
+
if (opts.model) {
|
|
102
|
+
args.push("--model", opts.model);
|
|
103
|
+
}
|
|
104
|
+
const cwd = opts.cwd || process.cwd();
|
|
105
|
+
args.push("--cd", cwd);
|
|
106
|
+
args.push(opts.prompt);
|
|
107
|
+
const env = buildSpawnEnv(undefined, opts.env);
|
|
108
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
109
|
+
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
|
|
110
|
+
const logFd = await fs.open(logPath, "w");
|
|
111
|
+
const codexPath = await resolveBinaryPath("codex");
|
|
112
|
+
const child = spawn(codexPath, args, {
|
|
113
|
+
cwd,
|
|
114
|
+
env,
|
|
115
|
+
stdio: ["ignore", logFd.fd, "ignore"],
|
|
116
|
+
detached: true,
|
|
117
|
+
});
|
|
118
|
+
child.on("error", (err) => {
|
|
119
|
+
console.error(`[codex] spawn error: ${err.message}`);
|
|
120
|
+
});
|
|
121
|
+
child.unref();
|
|
122
|
+
const pid = child.pid;
|
|
123
|
+
const now = new Date();
|
|
124
|
+
await logFd.close();
|
|
125
|
+
// Poll for thread_id from JSONL output
|
|
126
|
+
let resolvedSessionId;
|
|
127
|
+
if (pid) {
|
|
128
|
+
resolvedSessionId = await this.pollForSessionId(logPath, pid, 10000);
|
|
129
|
+
}
|
|
130
|
+
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
131
|
+
if (pid) {
|
|
132
|
+
await this.writeSessionMeta({
|
|
133
|
+
sessionId,
|
|
134
|
+
pid,
|
|
135
|
+
wrapperPid: process.pid,
|
|
136
|
+
cwd,
|
|
137
|
+
model: opts.model,
|
|
138
|
+
prompt: opts.prompt.slice(0, 200),
|
|
139
|
+
launchedAt: now.toISOString(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
id: sessionId,
|
|
144
|
+
adapter: this.id,
|
|
145
|
+
status: "running",
|
|
146
|
+
startedAt: now,
|
|
147
|
+
cwd,
|
|
148
|
+
model: opts.model,
|
|
149
|
+
prompt: opts.prompt.slice(0, 200),
|
|
150
|
+
pid,
|
|
151
|
+
meta: {
|
|
152
|
+
adapterOpts: opts.adapterOpts,
|
|
153
|
+
spec: opts.spec,
|
|
154
|
+
logPath,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Poll the launch log file for up to `timeoutMs` to extract the session/thread ID.
|
|
160
|
+
* Codex outputs {"type":"thread.started","thread_id":"..."} early in its JSONL stream.
|
|
161
|
+
*/
|
|
162
|
+
async pollForSessionId(logPath, pid, timeoutMs) {
|
|
163
|
+
const deadline = Date.now() + timeoutMs;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
try {
|
|
166
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
167
|
+
for (const line of content.split("\n")) {
|
|
168
|
+
if (!line.trim())
|
|
169
|
+
continue;
|
|
170
|
+
try {
|
|
171
|
+
const msg = JSON.parse(line);
|
|
172
|
+
// Look for thread.started event
|
|
173
|
+
if (msg.thread_id) {
|
|
174
|
+
return msg.thread_id;
|
|
175
|
+
}
|
|
176
|
+
// Also check session_meta payload
|
|
177
|
+
if (msg.type === "session_meta" && msg.payload?.id) {
|
|
178
|
+
return msg.payload.id;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Not valid JSON yet
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// File may not exist yet
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
await sleep(200);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
async stop(sessionId, opts) {
|
|
200
|
+
const pid = await this.findPidForSession(sessionId);
|
|
201
|
+
if (!pid)
|
|
202
|
+
throw new Error(`No running process for session: ${sessionId}`);
|
|
203
|
+
if (opts?.force) {
|
|
204
|
+
process.kill(pid, "SIGINT");
|
|
205
|
+
await sleep(5000);
|
|
206
|
+
try {
|
|
207
|
+
process.kill(pid, "SIGKILL");
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Already dead
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
process.kill(pid, "SIGTERM");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async resume(sessionId, message) {
|
|
218
|
+
const session = await this.findSession(sessionId);
|
|
219
|
+
const cwd = session?.cwd || process.cwd();
|
|
220
|
+
const args = [
|
|
221
|
+
"exec",
|
|
222
|
+
"resume",
|
|
223
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
224
|
+
"--json",
|
|
225
|
+
sessionId,
|
|
226
|
+
message,
|
|
227
|
+
];
|
|
228
|
+
const codexPath = await resolveBinaryPath("codex");
|
|
229
|
+
const child = spawn(codexPath, args, {
|
|
230
|
+
cwd,
|
|
231
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
232
|
+
detached: true,
|
|
233
|
+
});
|
|
234
|
+
child.on("error", (err) => {
|
|
235
|
+
console.error(`[codex] resume spawn error: ${err.message}`);
|
|
236
|
+
});
|
|
237
|
+
child.unref();
|
|
238
|
+
}
|
|
239
|
+
async *events() {
|
|
240
|
+
let knownSessions = new Map();
|
|
241
|
+
const initial = await this.list({ all: true });
|
|
242
|
+
for (const s of initial) {
|
|
243
|
+
knownSessions.set(s.id, s);
|
|
244
|
+
}
|
|
245
|
+
const watchDir = this.sessionsDir;
|
|
246
|
+
let watcher;
|
|
247
|
+
try {
|
|
248
|
+
await fs.access(watchDir);
|
|
249
|
+
watcher = watch(watchDir, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Sessions dir may not exist yet
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
while (true) {
|
|
256
|
+
await sleep(5000);
|
|
257
|
+
const current = await this.list({ all: true });
|
|
258
|
+
const currentMap = new Map(current.map((s) => [s.id, s]));
|
|
259
|
+
for (const [id, session] of currentMap) {
|
|
260
|
+
const prev = knownSessions.get(id);
|
|
261
|
+
if (!prev) {
|
|
262
|
+
yield {
|
|
263
|
+
type: "session.started",
|
|
264
|
+
adapter: this.id,
|
|
265
|
+
sessionId: id,
|
|
266
|
+
session,
|
|
267
|
+
timestamp: new Date(),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
else if (prev.status === "running" &&
|
|
271
|
+
session.status === "stopped") {
|
|
272
|
+
yield {
|
|
273
|
+
type: "session.stopped",
|
|
274
|
+
adapter: this.id,
|
|
275
|
+
sessionId: id,
|
|
276
|
+
session,
|
|
277
|
+
timestamp: new Date(),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
else if (prev.status === "running" && session.status === "idle") {
|
|
281
|
+
yield {
|
|
282
|
+
type: "session.idle",
|
|
283
|
+
adapter: this.id,
|
|
284
|
+
sessionId: id,
|
|
285
|
+
session,
|
|
286
|
+
timestamp: new Date(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
knownSessions = currentMap;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
watcher?.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// --- Private helpers ---
|
|
298
|
+
/**
|
|
299
|
+
* Discover all Codex sessions by scanning ~/.codex/sessions/ recursively.
|
|
300
|
+
* Sessions are stored as: sessions/YYYY/MM/DD/rollout-<datetime>-<session-id>.jsonl
|
|
301
|
+
*/
|
|
302
|
+
async discoverSessions() {
|
|
303
|
+
const sessions = [];
|
|
304
|
+
const jsonlFiles = await this.findJsonlFiles(this.sessionsDir);
|
|
305
|
+
for (const filePath of jsonlFiles) {
|
|
306
|
+
try {
|
|
307
|
+
const info = await this.parseSessionFile(filePath);
|
|
308
|
+
if (info)
|
|
309
|
+
sessions.push(info);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Skip unparseable files
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Also check persisted metadata for sessions not yet in ~/.codex/sessions/
|
|
316
|
+
try {
|
|
317
|
+
const metaFiles = await fs.readdir(this.sessionsMetaDir);
|
|
318
|
+
for (const file of metaFiles) {
|
|
319
|
+
if (!file.endsWith(".json") || file.startsWith("launch-"))
|
|
320
|
+
continue;
|
|
321
|
+
try {
|
|
322
|
+
const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
|
|
323
|
+
const meta = JSON.parse(raw);
|
|
324
|
+
// Skip if we already have this session from the sessions dir
|
|
325
|
+
if (sessions.some((s) => s.id === meta.sessionId))
|
|
326
|
+
continue;
|
|
327
|
+
sessions.push({
|
|
328
|
+
id: meta.sessionId,
|
|
329
|
+
cwd: meta.cwd,
|
|
330
|
+
model: meta.model,
|
|
331
|
+
firstPrompt: meta.prompt,
|
|
332
|
+
created: new Date(meta.launchedAt),
|
|
333
|
+
modified: new Date(meta.launchedAt),
|
|
334
|
+
filePath: "",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Skip
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Dir doesn't exist
|
|
344
|
+
}
|
|
345
|
+
return sessions;
|
|
346
|
+
}
|
|
347
|
+
/** Recursively find all .jsonl files under a directory */
|
|
348
|
+
async findJsonlFiles(dir) {
|
|
349
|
+
const results = [];
|
|
350
|
+
try {
|
|
351
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
const fullPath = path.join(dir, entry.name);
|
|
354
|
+
if (entry.isDirectory()) {
|
|
355
|
+
const nested = await this.findJsonlFiles(fullPath);
|
|
356
|
+
results.push(...nested);
|
|
357
|
+
}
|
|
358
|
+
else if (entry.name.endsWith(".jsonl")) {
|
|
359
|
+
results.push(fullPath);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Directory doesn't exist
|
|
365
|
+
}
|
|
366
|
+
return results;
|
|
367
|
+
}
|
|
368
|
+
/** Parse a Codex session JSONL file to extract session info */
|
|
369
|
+
async parseSessionFile(filePath) {
|
|
370
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
371
|
+
const lines = content.trim().split("\n");
|
|
372
|
+
if (lines.length === 0 || !lines[0].trim())
|
|
373
|
+
return null;
|
|
374
|
+
const stat = await fs.stat(filePath);
|
|
375
|
+
let id;
|
|
376
|
+
let cwd;
|
|
377
|
+
let model;
|
|
378
|
+
let cliVersion;
|
|
379
|
+
let firstPrompt;
|
|
380
|
+
let lastMessage;
|
|
381
|
+
let sessionTimestamp;
|
|
382
|
+
let totalIn = 0;
|
|
383
|
+
let totalOut = 0;
|
|
384
|
+
for (const line of lines) {
|
|
385
|
+
if (!line.trim())
|
|
386
|
+
continue;
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(line);
|
|
389
|
+
// Extract session ID from thread.started or session_meta
|
|
390
|
+
if (parsed.thread_id && !id) {
|
|
391
|
+
id = parsed.thread_id;
|
|
392
|
+
}
|
|
393
|
+
if (parsed.type === "session_meta" && parsed.payload) {
|
|
394
|
+
const meta = parsed.payload;
|
|
395
|
+
if (meta.id)
|
|
396
|
+
id = meta.id;
|
|
397
|
+
if (meta.cwd)
|
|
398
|
+
cwd = meta.cwd;
|
|
399
|
+
if (meta.cli_version)
|
|
400
|
+
cliVersion = meta.cli_version;
|
|
401
|
+
}
|
|
402
|
+
// Capture the earliest timestamp from the file
|
|
403
|
+
if (parsed.timestamp && !sessionTimestamp) {
|
|
404
|
+
sessionTimestamp = parsed.timestamp;
|
|
405
|
+
}
|
|
406
|
+
// Extract model from turn_context
|
|
407
|
+
if (parsed.type === "turn_context" && parsed.payload?.model) {
|
|
408
|
+
model = parsed.payload.model;
|
|
409
|
+
}
|
|
410
|
+
// Extract first user prompt
|
|
411
|
+
if (parsed.type === "event_msg" &&
|
|
412
|
+
parsed.payload?.type === "user_message" &&
|
|
413
|
+
parsed.payload.message &&
|
|
414
|
+
!firstPrompt) {
|
|
415
|
+
firstPrompt = parsed.payload.message;
|
|
416
|
+
}
|
|
417
|
+
// Extract agent messages
|
|
418
|
+
if (parsed.type === "event_msg" &&
|
|
419
|
+
parsed.payload?.type === "agent_message" &&
|
|
420
|
+
parsed.payload.message) {
|
|
421
|
+
lastMessage = parsed.payload.message;
|
|
422
|
+
}
|
|
423
|
+
// Extract token usage
|
|
424
|
+
if (parsed.type === "event_msg" &&
|
|
425
|
+
parsed.payload?.type === "token_count" &&
|
|
426
|
+
parsed.payload.info?.total_token_usage) {
|
|
427
|
+
const usage = parsed.payload.info.total_token_usage;
|
|
428
|
+
totalIn = usage.input_tokens ?? 0;
|
|
429
|
+
totalOut = usage.output_tokens ?? 0;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// skip malformed lines
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (!id) {
|
|
437
|
+
// Try to extract ID from filename:
|
|
438
|
+
// rollout-2026-02-20T17-38-59-019c7dd9-9b86-7dc1-95fe-7b68b8fd260d.jsonl
|
|
439
|
+
const basename = path.basename(filePath, ".jsonl");
|
|
440
|
+
const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
441
|
+
if (uuidMatch) {
|
|
442
|
+
id = uuidMatch[1];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!id)
|
|
446
|
+
return null;
|
|
447
|
+
// Use session's own timestamp if available, fall back to file stat
|
|
448
|
+
const created = sessionTimestamp
|
|
449
|
+
? new Date(sessionTimestamp)
|
|
450
|
+
: stat.birthtime;
|
|
451
|
+
return {
|
|
452
|
+
id,
|
|
453
|
+
cwd,
|
|
454
|
+
model,
|
|
455
|
+
cliVersion,
|
|
456
|
+
firstPrompt: firstPrompt?.slice(0, 200),
|
|
457
|
+
lastMessage,
|
|
458
|
+
tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
|
|
459
|
+
created,
|
|
460
|
+
modified: stat.mtime,
|
|
461
|
+
filePath,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
buildSession(info, runningPids) {
|
|
465
|
+
const isRunning = this.isSessionRunning(info, runningPids);
|
|
466
|
+
const pid = isRunning ? this.findMatchingPid(info, runningPids) : undefined;
|
|
467
|
+
return {
|
|
468
|
+
id: info.id,
|
|
469
|
+
adapter: this.id,
|
|
470
|
+
status: isRunning ? "running" : "stopped",
|
|
471
|
+
startedAt: info.created,
|
|
472
|
+
stoppedAt: isRunning ? undefined : info.modified,
|
|
473
|
+
cwd: info.cwd,
|
|
474
|
+
model: info.model,
|
|
475
|
+
prompt: info.firstPrompt,
|
|
476
|
+
tokens: info.tokens,
|
|
477
|
+
pid,
|
|
478
|
+
meta: {
|
|
479
|
+
cliVersion: info.cliVersion,
|
|
480
|
+
lastMessage: info.lastMessage,
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
isSessionRunning(info, runningPids) {
|
|
485
|
+
const sessionCreated = info.created.getTime();
|
|
486
|
+
// 1. Check running PIDs from ps aux
|
|
487
|
+
for (const [, pidInfo] of runningPids) {
|
|
488
|
+
if (pidInfo.args.includes(info.id)) {
|
|
489
|
+
if (this.processStartedAfterSession(pidInfo, sessionCreated))
|
|
490
|
+
return true;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (info.cwd && pidInfo.cwd === info.cwd) {
|
|
494
|
+
if (this.processStartedAfterSession(pidInfo, sessionCreated))
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// 2. Check persisted session metadata
|
|
499
|
+
const meta = this.readSessionMetaSync(info.id);
|
|
500
|
+
if (meta?.pid) {
|
|
501
|
+
if (this.isProcessAlive(meta.pid)) {
|
|
502
|
+
// Cross-check start time for PID recycling
|
|
503
|
+
const pidInfo = runningPids.get(meta.pid);
|
|
504
|
+
if (pidInfo?.startTime && meta.startTime) {
|
|
505
|
+
const currentStartMs = new Date(pidInfo.startTime).getTime();
|
|
506
|
+
const recordedStartMs = new Date(meta.startTime).getTime();
|
|
507
|
+
if (!Number.isNaN(currentStartMs) &&
|
|
508
|
+
!Number.isNaN(recordedStartMs) &&
|
|
509
|
+
Math.abs(currentStartMs - recordedStartMs) > 5000) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (meta.startTime) {
|
|
514
|
+
const metaStartMs = new Date(meta.startTime).getTime();
|
|
515
|
+
const sessionMs = new Date(meta.launchedAt).getTime();
|
|
516
|
+
if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
processStartedAfterSession(info, sessionCreatedMs) {
|
|
527
|
+
if (!info.startTime)
|
|
528
|
+
return false;
|
|
529
|
+
const processStartMs = new Date(info.startTime).getTime();
|
|
530
|
+
if (Number.isNaN(processStartMs))
|
|
531
|
+
return false;
|
|
532
|
+
return processStartMs >= sessionCreatedMs - 5000;
|
|
533
|
+
}
|
|
534
|
+
findMatchingPid(info, runningPids) {
|
|
535
|
+
const sessionCreated = info.created.getTime();
|
|
536
|
+
for (const [pid, pidInfo] of runningPids) {
|
|
537
|
+
if (pidInfo.args.includes(info.id)) {
|
|
538
|
+
if (this.processStartedAfterSession(pidInfo, sessionCreated))
|
|
539
|
+
return pid;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (info.cwd && pidInfo.cwd === info.cwd) {
|
|
543
|
+
if (this.processStartedAfterSession(pidInfo, sessionCreated))
|
|
544
|
+
return pid;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const meta = this.readSessionMetaSync(info.id);
|
|
548
|
+
if (meta?.pid && this.isProcessAlive(meta.pid)) {
|
|
549
|
+
return meta.pid;
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
async findSession(sessionId) {
|
|
554
|
+
const sessions = await this.discoverSessions();
|
|
555
|
+
// Exact match
|
|
556
|
+
const exact = sessions.find((s) => s.id === sessionId);
|
|
557
|
+
if (exact)
|
|
558
|
+
return exact;
|
|
559
|
+
// Prefix match
|
|
560
|
+
const prefix = sessions.find((s) => s.id.startsWith(sessionId));
|
|
561
|
+
return prefix || null;
|
|
562
|
+
}
|
|
563
|
+
async findPidForSession(sessionId) {
|
|
564
|
+
const session = await this.status(sessionId);
|
|
565
|
+
return session.pid ?? null;
|
|
566
|
+
}
|
|
567
|
+
// --- Session metadata persistence ---
|
|
568
|
+
async writeSessionMeta(meta) {
|
|
569
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
570
|
+
let startTime;
|
|
571
|
+
try {
|
|
572
|
+
const { stdout } = await execFileAsync("ps", [
|
|
573
|
+
"-p",
|
|
574
|
+
meta.pid.toString(),
|
|
575
|
+
"-o",
|
|
576
|
+
"lstart=",
|
|
577
|
+
]);
|
|
578
|
+
startTime = stdout.trim() || undefined;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Process may have already exited
|
|
582
|
+
}
|
|
583
|
+
const fullMeta = { ...meta, startTime };
|
|
584
|
+
const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
|
|
585
|
+
await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
|
|
586
|
+
}
|
|
587
|
+
async readSessionMeta(sessionId) {
|
|
588
|
+
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
|
|
589
|
+
try {
|
|
590
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
591
|
+
return JSON.parse(raw);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Not found
|
|
595
|
+
}
|
|
596
|
+
// Scan all metadata files for matching sessionId
|
|
597
|
+
try {
|
|
598
|
+
const files = await fs.readdir(this.sessionsMetaDir);
|
|
599
|
+
for (const file of files) {
|
|
600
|
+
if (!file.endsWith(".json") || file.startsWith("launch-"))
|
|
601
|
+
continue;
|
|
602
|
+
try {
|
|
603
|
+
const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
|
|
604
|
+
const meta = JSON.parse(raw);
|
|
605
|
+
if (meta.sessionId === sessionId)
|
|
606
|
+
return meta;
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// skip
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Dir doesn't exist
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Synchronous-style read of session metadata (reads from cache/disk).
|
|
620
|
+
* Used by isSessionRunning which is called in a tight loop.
|
|
621
|
+
* Falls back to null if not found.
|
|
622
|
+
*/
|
|
623
|
+
readSessionMetaSync(sessionId) {
|
|
624
|
+
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
|
|
625
|
+
try {
|
|
626
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
627
|
+
return JSON.parse(raw);
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// --- Utility functions ---
|
|
635
|
+
function defaultIsProcessAlive(pid) {
|
|
636
|
+
try {
|
|
637
|
+
process.kill(pid, 0);
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function getCodexPids() {
|
|
645
|
+
const pids = new Map();
|
|
646
|
+
try {
|
|
647
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
648
|
+
for (const line of stdout.split("\n")) {
|
|
649
|
+
if (!line.includes("codex") || line.includes("grep"))
|
|
650
|
+
continue;
|
|
651
|
+
const fields = line.trim().split(/\s+/);
|
|
652
|
+
if (fields.length < 11)
|
|
653
|
+
continue;
|
|
654
|
+
const pid = parseInt(fields[1], 10);
|
|
655
|
+
const command = fields.slice(10).join(" ");
|
|
656
|
+
// Match codex exec or codex with flags — exclude interactive sessions
|
|
657
|
+
if (!command.startsWith("codex exec") && !command.startsWith("codex --"))
|
|
658
|
+
continue;
|
|
659
|
+
if (pid === process.pid)
|
|
660
|
+
continue;
|
|
661
|
+
let cwd = "";
|
|
662
|
+
try {
|
|
663
|
+
const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
|
|
664
|
+
"-p",
|
|
665
|
+
pid.toString(),
|
|
666
|
+
"-Fn",
|
|
667
|
+
]);
|
|
668
|
+
const lsofLines = lsofOut.split("\n");
|
|
669
|
+
for (let i = 0; i < lsofLines.length; i++) {
|
|
670
|
+
if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
|
|
671
|
+
cwd = lsofLines[i + 1].slice(1);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// lsof might fail
|
|
678
|
+
}
|
|
679
|
+
let startTime;
|
|
680
|
+
try {
|
|
681
|
+
const { stdout: lstart } = await execFileAsync("ps", [
|
|
682
|
+
"-p",
|
|
683
|
+
pid.toString(),
|
|
684
|
+
"-o",
|
|
685
|
+
"lstart=",
|
|
686
|
+
]);
|
|
687
|
+
startTime = lstart.trim() || undefined;
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
// ps might fail
|
|
691
|
+
}
|
|
692
|
+
pids.set(pid, { pid, cwd, args: command, startTime });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// ps failed
|
|
697
|
+
}
|
|
698
|
+
return pids;
|
|
699
|
+
}
|
|
700
|
+
function sleep(ms) {
|
|
701
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
702
|
+
}
|