@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,865 @@
|
|
|
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
|
+
import { buildSpawnEnv } from "../utils/daemon-env.js";
|
|
9
|
+
import { resolveBinaryPath } from "../utils/resolve-binary.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const DEFAULT_PI_DIR = path.join(os.homedir(), ".pi");
|
|
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
|
+
* Pi adapter — reads session data from ~/.pi/agent/sessions/
|
|
16
|
+
* and cross-references with running PIDs. NEVER maintains its own registry.
|
|
17
|
+
*
|
|
18
|
+
* Pi stores sessions as JSONL files in ~/.pi/agent/sessions/<cwd-slug>/<timestamp>_<id>.jsonl
|
|
19
|
+
* Each file starts with a type:'session' header line containing metadata.
|
|
20
|
+
*/
|
|
21
|
+
export class PiAdapter {
|
|
22
|
+
id = "pi";
|
|
23
|
+
piDir;
|
|
24
|
+
sessionsDir;
|
|
25
|
+
sessionsMetaDir;
|
|
26
|
+
getPids;
|
|
27
|
+
isProcessAlive;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.piDir = opts?.piDir || DEFAULT_PI_DIR;
|
|
30
|
+
this.sessionsDir = path.join(this.piDir, "agent", "sessions");
|
|
31
|
+
this.sessionsMetaDir =
|
|
32
|
+
opts?.sessionsMetaDir || path.join(this.piDir, "agentctl", "sessions");
|
|
33
|
+
this.getPids = opts?.getPids || getPiPids;
|
|
34
|
+
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
35
|
+
}
|
|
36
|
+
async list(opts) {
|
|
37
|
+
const runningPids = await this.getPids();
|
|
38
|
+
const discovered = await this.discoverSessions();
|
|
39
|
+
const sessions = [];
|
|
40
|
+
for (const disc of discovered) {
|
|
41
|
+
const session = await this.buildSession(disc, runningPids);
|
|
42
|
+
// Filter by status
|
|
43
|
+
if (opts?.status && session.status !== opts.status)
|
|
44
|
+
continue;
|
|
45
|
+
// If not --all, skip old stopped sessions
|
|
46
|
+
if (!opts?.all && session.status === "stopped") {
|
|
47
|
+
const age = Date.now() - session.startedAt.getTime();
|
|
48
|
+
if (age > STOPPED_SESSION_MAX_AGE_MS)
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Default: only show running sessions unless --all
|
|
52
|
+
if (!opts?.all &&
|
|
53
|
+
!opts?.status &&
|
|
54
|
+
session.status !== "running" &&
|
|
55
|
+
session.status !== "idle") {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
sessions.push(session);
|
|
59
|
+
}
|
|
60
|
+
// Sort: running first, then by most recent
|
|
61
|
+
sessions.sort((a, b) => {
|
|
62
|
+
if (a.status === "running" && b.status !== "running")
|
|
63
|
+
return -1;
|
|
64
|
+
if (b.status === "running" && a.status !== "running")
|
|
65
|
+
return 1;
|
|
66
|
+
return b.startedAt.getTime() - a.startedAt.getTime();
|
|
67
|
+
});
|
|
68
|
+
return sessions;
|
|
69
|
+
}
|
|
70
|
+
async peek(sessionId, opts) {
|
|
71
|
+
const lines = opts?.lines ?? 20;
|
|
72
|
+
const disc = await this.findSession(sessionId);
|
|
73
|
+
// Fallback for pending-* sessions: read from the launch log file
|
|
74
|
+
if (!disc) {
|
|
75
|
+
const meta = await this.readSessionMeta(sessionId);
|
|
76
|
+
if (meta?.sessionId) {
|
|
77
|
+
// Try to find the session by the metadata's session ID
|
|
78
|
+
const resolved = await this.findSession(meta.sessionId);
|
|
79
|
+
if (resolved) {
|
|
80
|
+
return this.peekFromJsonl(resolved.filePath, lines);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const logPath = await this.getLogPathForSession(sessionId);
|
|
84
|
+
if (logPath) {
|
|
85
|
+
try {
|
|
86
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
87
|
+
const logLines = content.trim().split("\n");
|
|
88
|
+
return logLines.slice(-lines).join("\n") || "(no output)";
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// log file unreadable
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
95
|
+
}
|
|
96
|
+
return this.peekFromJsonl(disc.filePath, lines);
|
|
97
|
+
}
|
|
98
|
+
/** Extract assistant messages from a JSONL session file */
|
|
99
|
+
async peekFromJsonl(filePath, lines) {
|
|
100
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
101
|
+
const jsonlLines = content.trim().split("\n");
|
|
102
|
+
const assistantMessages = [];
|
|
103
|
+
for (const line of jsonlLines) {
|
|
104
|
+
try {
|
|
105
|
+
const entry = JSON.parse(line);
|
|
106
|
+
if (entry.type === "message") {
|
|
107
|
+
const msg = entry;
|
|
108
|
+
const payload = msg.message;
|
|
109
|
+
if (payload?.role === "assistant" && payload.content) {
|
|
110
|
+
const text = extractContent(payload.content);
|
|
111
|
+
if (text)
|
|
112
|
+
assistantMessages.push(text);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// skip malformed lines
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Take last N messages
|
|
121
|
+
const recent = assistantMessages.slice(-lines);
|
|
122
|
+
return recent.join("\n---\n");
|
|
123
|
+
}
|
|
124
|
+
/** Get the log file path for a pending session from metadata */
|
|
125
|
+
async getLogPathForSession(sessionId) {
|
|
126
|
+
const meta = await this.readSessionMeta(sessionId);
|
|
127
|
+
if (!meta)
|
|
128
|
+
return null;
|
|
129
|
+
// The log path is stored in the launch metadata directory
|
|
130
|
+
const logPath = path.join(this.sessionsMetaDir, `launch-${new Date(meta.launchedAt).getTime()}.log`);
|
|
131
|
+
try {
|
|
132
|
+
await fs.access(logPath);
|
|
133
|
+
return logPath;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Also scan for log files near the launch time
|
|
137
|
+
try {
|
|
138
|
+
const files = await fs.readdir(this.sessionsMetaDir);
|
|
139
|
+
const launchMs = new Date(meta.launchedAt).getTime();
|
|
140
|
+
for (const file of files) {
|
|
141
|
+
if (!file.startsWith("launch-") || !file.endsWith(".log"))
|
|
142
|
+
continue;
|
|
143
|
+
const tsStr = file.replace("launch-", "").replace(".log", "");
|
|
144
|
+
const ts = Number(tsStr);
|
|
145
|
+
if (!Number.isNaN(ts) && Math.abs(ts - launchMs) < 2000) {
|
|
146
|
+
return path.join(this.sessionsMetaDir, file);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// dir doesn't exist
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
async status(sessionId) {
|
|
157
|
+
const runningPids = await this.getPids();
|
|
158
|
+
const disc = await this.findSession(sessionId);
|
|
159
|
+
if (!disc)
|
|
160
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
161
|
+
return this.buildSession(disc, runningPids);
|
|
162
|
+
}
|
|
163
|
+
async launch(opts) {
|
|
164
|
+
const args = ["-p", opts.prompt, "--mode", "json"];
|
|
165
|
+
if (opts.model) {
|
|
166
|
+
args.unshift("--model", opts.model);
|
|
167
|
+
}
|
|
168
|
+
const env = buildSpawnEnv(undefined, opts.env);
|
|
169
|
+
const cwd = opts.cwd || process.cwd();
|
|
170
|
+
// Write stdout to a log file so we can extract the session ID
|
|
171
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
172
|
+
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
|
|
173
|
+
const logFd = await fs.open(logPath, "w");
|
|
174
|
+
const piPath = await resolveBinaryPath("pi");
|
|
175
|
+
const child = spawn(piPath, args, {
|
|
176
|
+
cwd,
|
|
177
|
+
env,
|
|
178
|
+
stdio: ["ignore", logFd.fd, logFd.fd],
|
|
179
|
+
detached: true,
|
|
180
|
+
});
|
|
181
|
+
child.on("error", (err) => {
|
|
182
|
+
console.error(`[pi] spawn error: ${err.message}`);
|
|
183
|
+
});
|
|
184
|
+
// Fully detach: child runs in its own process group.
|
|
185
|
+
child.unref();
|
|
186
|
+
const pid = child.pid;
|
|
187
|
+
const now = new Date();
|
|
188
|
+
// Close our handle — child keeps its own fd open
|
|
189
|
+
await logFd.close();
|
|
190
|
+
// Try to extract the session ID from the JSON-mode stdout output.
|
|
191
|
+
// Pi's first JSON line has type: "session" with an id field.
|
|
192
|
+
let resolvedSessionId;
|
|
193
|
+
if (pid) {
|
|
194
|
+
resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
|
|
195
|
+
// Fallback: scan session files directory for a newly created file
|
|
196
|
+
if (!resolvedSessionId) {
|
|
197
|
+
resolvedSessionId = await this.pollSessionDir(cwd, now, pid, 3000);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
201
|
+
// Persist session metadata so status checks work after wrapper exits
|
|
202
|
+
if (pid) {
|
|
203
|
+
await this.writeSessionMeta({
|
|
204
|
+
sessionId,
|
|
205
|
+
pid,
|
|
206
|
+
wrapperPid: process.pid,
|
|
207
|
+
cwd,
|
|
208
|
+
model: opts.model,
|
|
209
|
+
prompt: opts.prompt.slice(0, 200),
|
|
210
|
+
launchedAt: now.toISOString(),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const session = {
|
|
214
|
+
id: sessionId,
|
|
215
|
+
adapter: this.id,
|
|
216
|
+
status: "running",
|
|
217
|
+
startedAt: now,
|
|
218
|
+
cwd,
|
|
219
|
+
model: opts.model,
|
|
220
|
+
prompt: opts.prompt.slice(0, 200),
|
|
221
|
+
pid,
|
|
222
|
+
meta: {
|
|
223
|
+
adapterOpts: opts.adapterOpts,
|
|
224
|
+
spec: opts.spec,
|
|
225
|
+
logPath,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
return session;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Poll the launch log file for up to `timeoutMs` to extract the real session ID.
|
|
232
|
+
* Pi's JSONL output includes a session header with type: "session" and id field.
|
|
233
|
+
*/
|
|
234
|
+
async pollForSessionId(logPath, pid, timeoutMs) {
|
|
235
|
+
const deadline = Date.now() + timeoutMs;
|
|
236
|
+
while (Date.now() < deadline) {
|
|
237
|
+
try {
|
|
238
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
239
|
+
for (const line of content.split("\n")) {
|
|
240
|
+
if (!line.trim())
|
|
241
|
+
continue;
|
|
242
|
+
try {
|
|
243
|
+
const msg = JSON.parse(line);
|
|
244
|
+
// Pi session header: type "session" with id
|
|
245
|
+
if (msg.type === "session" &&
|
|
246
|
+
msg.id &&
|
|
247
|
+
typeof msg.id === "string") {
|
|
248
|
+
return msg.id;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Not valid JSON yet
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// File may not exist yet
|
|
258
|
+
}
|
|
259
|
+
// Check if process is still alive
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, 0);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
break; // Process died
|
|
265
|
+
}
|
|
266
|
+
await sleep(200);
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Fallback: poll the Pi sessions directory for a new JSONL file
|
|
272
|
+
* created after the launch time, matching the cwd.
|
|
273
|
+
*/
|
|
274
|
+
async pollSessionDir(cwd, launchTime, pid, timeoutMs) {
|
|
275
|
+
// Resolve symlinks (macOS: /tmp → /private/tmp) so slug matches Pi's resolved path
|
|
276
|
+
let resolvedCwd = cwd;
|
|
277
|
+
try {
|
|
278
|
+
resolvedCwd = await fs.realpath(cwd);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Path might not exist yet — use original
|
|
282
|
+
}
|
|
283
|
+
// Pi uses a cwd-slug directory: cwd with / replaced by - and surrounded by --
|
|
284
|
+
const cwdSlug = `--${resolvedCwd.replace(/\//g, "-")}--`;
|
|
285
|
+
const slugDir = path.join(this.sessionsDir, cwdSlug);
|
|
286
|
+
const deadline = Date.now() + timeoutMs;
|
|
287
|
+
while (Date.now() < deadline) {
|
|
288
|
+
try {
|
|
289
|
+
const files = await fs.readdir(slugDir);
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
if (!file.endsWith(".jsonl"))
|
|
292
|
+
continue;
|
|
293
|
+
const filePath = path.join(slugDir, file);
|
|
294
|
+
const stat = await fs.stat(filePath);
|
|
295
|
+
// Only consider files created after (or very near) launch time
|
|
296
|
+
if (stat.birthtimeMs >= launchTime.getTime() - 2000) {
|
|
297
|
+
const header = await this.parseSessionHeader(filePath);
|
|
298
|
+
if (header?.id)
|
|
299
|
+
return header.id;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Dir may not exist yet
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
process.kill(pid, 0);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
await sleep(200);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
async stop(sessionId, opts) {
|
|
317
|
+
const pid = await this.findPidForSession(sessionId);
|
|
318
|
+
if (!pid)
|
|
319
|
+
throw new Error(`No running process for session: ${sessionId}`);
|
|
320
|
+
if (opts?.force) {
|
|
321
|
+
// SIGINT first, then SIGKILL after 5s
|
|
322
|
+
process.kill(pid, "SIGINT");
|
|
323
|
+
await sleep(5000);
|
|
324
|
+
try {
|
|
325
|
+
process.kill(pid, "SIGKILL");
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// Already dead — good
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
process.kill(pid, "SIGTERM");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async resume(sessionId, message) {
|
|
336
|
+
// Pi doesn't have a native --continue flag.
|
|
337
|
+
// Launch a new pi session in the same cwd with the continuation message.
|
|
338
|
+
const disc = await this.findSession(sessionId);
|
|
339
|
+
const cwd = disc?.header.cwd || process.cwd();
|
|
340
|
+
const piPath = await resolveBinaryPath("pi");
|
|
341
|
+
const child = spawn(piPath, ["-p", message], {
|
|
342
|
+
cwd,
|
|
343
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
344
|
+
detached: true,
|
|
345
|
+
});
|
|
346
|
+
child.on("error", (err) => {
|
|
347
|
+
console.error(`[pi] resume spawn error: ${err.message}`);
|
|
348
|
+
});
|
|
349
|
+
child.unref();
|
|
350
|
+
}
|
|
351
|
+
async *events() {
|
|
352
|
+
// Track known sessions to detect transitions
|
|
353
|
+
let knownSessions = new Map();
|
|
354
|
+
// Initial snapshot
|
|
355
|
+
const initial = await this.list({ all: true });
|
|
356
|
+
for (const s of initial) {
|
|
357
|
+
knownSessions.set(s.id, s);
|
|
358
|
+
}
|
|
359
|
+
// Poll + fs.watch hybrid
|
|
360
|
+
const watcher = watch(this.sessionsDir, { recursive: true });
|
|
361
|
+
try {
|
|
362
|
+
while (true) {
|
|
363
|
+
await sleep(5000);
|
|
364
|
+
const current = await this.list({ all: true });
|
|
365
|
+
const currentMap = new Map(current.map((s) => [s.id, s]));
|
|
366
|
+
// Detect new sessions
|
|
367
|
+
for (const [id, session] of currentMap) {
|
|
368
|
+
const prev = knownSessions.get(id);
|
|
369
|
+
if (!prev) {
|
|
370
|
+
yield {
|
|
371
|
+
type: "session.started",
|
|
372
|
+
adapter: this.id,
|
|
373
|
+
sessionId: id,
|
|
374
|
+
session,
|
|
375
|
+
timestamp: new Date(),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
else if (prev.status === "running" &&
|
|
379
|
+
session.status === "stopped") {
|
|
380
|
+
yield {
|
|
381
|
+
type: "session.stopped",
|
|
382
|
+
adapter: this.id,
|
|
383
|
+
sessionId: id,
|
|
384
|
+
session,
|
|
385
|
+
timestamp: new Date(),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
else if (prev.status === "running" && session.status === "idle") {
|
|
389
|
+
yield {
|
|
390
|
+
type: "session.idle",
|
|
391
|
+
adapter: this.id,
|
|
392
|
+
sessionId: id,
|
|
393
|
+
session,
|
|
394
|
+
timestamp: new Date(),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
knownSessions = currentMap;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
watcher.close();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// --- Private helpers ---
|
|
406
|
+
/**
|
|
407
|
+
* Scan ~/.pi/agent/sessions/ recursively for .jsonl files and parse headers.
|
|
408
|
+
* Pi stores sessions at <sessionsDir>/<cwd-slug>/<timestamp>_<id>.jsonl
|
|
409
|
+
*/
|
|
410
|
+
async discoverSessions() {
|
|
411
|
+
const results = [];
|
|
412
|
+
let cwdSlugs;
|
|
413
|
+
try {
|
|
414
|
+
cwdSlugs = await fs.readdir(this.sessionsDir);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
for (const slug of cwdSlugs) {
|
|
420
|
+
const slugDir = path.join(this.sessionsDir, slug);
|
|
421
|
+
const stat = await fs.stat(slugDir).catch(() => null);
|
|
422
|
+
if (!stat?.isDirectory())
|
|
423
|
+
continue;
|
|
424
|
+
let files;
|
|
425
|
+
try {
|
|
426
|
+
files = await fs.readdir(slugDir);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
if (!file.endsWith(".jsonl"))
|
|
433
|
+
continue;
|
|
434
|
+
const filePath = path.join(slugDir, file);
|
|
435
|
+
let fileStat;
|
|
436
|
+
try {
|
|
437
|
+
fileStat = await fs.stat(filePath);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// Parse session header from first lines
|
|
443
|
+
const header = await this.parseSessionHeader(filePath);
|
|
444
|
+
if (!header)
|
|
445
|
+
continue;
|
|
446
|
+
// Extract session ID from header or filename
|
|
447
|
+
// Filename format: <timestamp>_<id>.jsonl
|
|
448
|
+
const sessionId = header.id || this.extractSessionIdFromFilename(file);
|
|
449
|
+
if (!sessionId)
|
|
450
|
+
continue;
|
|
451
|
+
results.push({
|
|
452
|
+
sessionId,
|
|
453
|
+
filePath,
|
|
454
|
+
header: { ...header, id: sessionId },
|
|
455
|
+
created: fileStat.birthtime,
|
|
456
|
+
modified: fileStat.mtime,
|
|
457
|
+
cwdSlug: slug,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return results;
|
|
462
|
+
}
|
|
463
|
+
/** Extract session ID from filename format: <timestamp>_<id>.jsonl */
|
|
464
|
+
extractSessionIdFromFilename(filename) {
|
|
465
|
+
const base = filename.replace(".jsonl", "");
|
|
466
|
+
const underscoreIdx = base.indexOf("_");
|
|
467
|
+
if (underscoreIdx >= 0) {
|
|
468
|
+
return base.slice(underscoreIdx + 1);
|
|
469
|
+
}
|
|
470
|
+
return base; // fallback: use entire filename as ID
|
|
471
|
+
}
|
|
472
|
+
/** Parse the session header (type:'session') from the first few lines of a JSONL file */
|
|
473
|
+
async parseSessionHeader(filePath) {
|
|
474
|
+
try {
|
|
475
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
476
|
+
for (const line of content.split("\n").slice(0, 10)) {
|
|
477
|
+
if (!line.trim())
|
|
478
|
+
continue;
|
|
479
|
+
try {
|
|
480
|
+
const entry = JSON.parse(line);
|
|
481
|
+
if (entry.type === "session") {
|
|
482
|
+
return entry;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// skip malformed line
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// file unreadable
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
async buildSession(disc, runningPids) {
|
|
496
|
+
const isRunning = await this.isSessionRunning(disc, runningPids);
|
|
497
|
+
const { model, tokens, cost } = await this.parseSessionTail(disc.filePath, disc.header);
|
|
498
|
+
return {
|
|
499
|
+
id: disc.sessionId,
|
|
500
|
+
adapter: this.id,
|
|
501
|
+
status: isRunning ? "running" : "stopped",
|
|
502
|
+
startedAt: disc.created,
|
|
503
|
+
stoppedAt: isRunning ? undefined : disc.modified,
|
|
504
|
+
cwd: disc.header.cwd,
|
|
505
|
+
model,
|
|
506
|
+
prompt: await this.getFirstPrompt(disc.filePath),
|
|
507
|
+
tokens,
|
|
508
|
+
cost,
|
|
509
|
+
pid: isRunning
|
|
510
|
+
? await this.findMatchingPid(disc, runningPids)
|
|
511
|
+
: undefined,
|
|
512
|
+
meta: {
|
|
513
|
+
provider: disc.header.provider,
|
|
514
|
+
thinkingLevel: disc.header.thinkingLevel,
|
|
515
|
+
version: disc.header.version,
|
|
516
|
+
cwdSlug: disc.cwdSlug,
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async isSessionRunning(disc, runningPids) {
|
|
521
|
+
const sessionCwd = disc.header.cwd;
|
|
522
|
+
if (!sessionCwd)
|
|
523
|
+
return false;
|
|
524
|
+
const sessionCreated = disc.created.getTime();
|
|
525
|
+
// 1. Check running PIDs discovered via `ps aux`
|
|
526
|
+
for (const [, info] of runningPids) {
|
|
527
|
+
// Check if the session ID appears in the command args — most reliable match
|
|
528
|
+
if (info.args.includes(disc.sessionId)) {
|
|
529
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
530
|
+
return true;
|
|
531
|
+
// PID recycling: process started before this session existed
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// Match by cwd — less specific (multiple sessions share a project)
|
|
535
|
+
if (info.cwd === sessionCwd) {
|
|
536
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// 2. Check persisted session metadata (for detached processes that
|
|
541
|
+
// may not appear in `ps aux` filtering, e.g. after wrapper exit)
|
|
542
|
+
const meta = await this.readSessionMeta(disc.sessionId);
|
|
543
|
+
if (meta?.pid) {
|
|
544
|
+
// Verify the persisted PID is still alive
|
|
545
|
+
if (this.isProcessAlive(meta.pid)) {
|
|
546
|
+
// Cross-check: if this PID appears in runningPids with a DIFFERENT
|
|
547
|
+
// start time than what we recorded, the PID was recycled.
|
|
548
|
+
const pidInfo = runningPids.get(meta.pid);
|
|
549
|
+
if (pidInfo?.startTime && meta.startTime) {
|
|
550
|
+
const currentStartMs = new Date(pidInfo.startTime).getTime();
|
|
551
|
+
const recordedStartMs = new Date(meta.startTime).getTime();
|
|
552
|
+
if (!Number.isNaN(currentStartMs) &&
|
|
553
|
+
!Number.isNaN(recordedStartMs) &&
|
|
554
|
+
Math.abs(currentStartMs - recordedStartMs) > 5000) {
|
|
555
|
+
// Process at this PID has a different start time — recycled
|
|
556
|
+
await this.deleteSessionMeta(disc.sessionId);
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Verify stored start time is consistent with launch time
|
|
561
|
+
if (meta.startTime) {
|
|
562
|
+
const metaStartMs = new Date(meta.startTime).getTime();
|
|
563
|
+
const sessionMs = new Date(meta.launchedAt).getTime();
|
|
564
|
+
if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
// Start time doesn't match — PID was recycled, clean up stale metadata
|
|
568
|
+
await this.deleteSessionMeta(disc.sessionId);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
// No start time in metadata — can't verify, assume alive
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
// PID is dead — clean up stale metadata
|
|
575
|
+
await this.deleteSessionMeta(disc.sessionId);
|
|
576
|
+
}
|
|
577
|
+
// 3. Fallback: check if JSONL was modified very recently (last 60s)
|
|
578
|
+
try {
|
|
579
|
+
const stat = await fs.stat(disc.filePath);
|
|
580
|
+
const age = Date.now() - stat.mtimeMs;
|
|
581
|
+
if (age < 60_000) {
|
|
582
|
+
for (const [, info] of runningPids) {
|
|
583
|
+
if (info.cwd === sessionCwd &&
|
|
584
|
+
this.processStartedAfterSession(info, sessionCreated)) {
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// file doesn't exist
|
|
592
|
+
}
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Check whether a process plausibly belongs to a session by verifying
|
|
597
|
+
* the process started at or after the session's creation time.
|
|
598
|
+
* When start time is unavailable, defaults to false (assume no match).
|
|
599
|
+
*/
|
|
600
|
+
processStartedAfterSession(info, sessionCreatedMs) {
|
|
601
|
+
if (!info.startTime)
|
|
602
|
+
return false;
|
|
603
|
+
const processStartMs = new Date(info.startTime).getTime();
|
|
604
|
+
if (Number.isNaN(processStartMs))
|
|
605
|
+
return false;
|
|
606
|
+
// Allow 5s tolerance for clock skew
|
|
607
|
+
return processStartMs >= sessionCreatedMs - 5000;
|
|
608
|
+
}
|
|
609
|
+
async findMatchingPid(disc, runningPids) {
|
|
610
|
+
const sessionCwd = disc.header.cwd;
|
|
611
|
+
const sessionCreated = disc.created.getTime();
|
|
612
|
+
for (const [pid, info] of runningPids) {
|
|
613
|
+
if (info.args.includes(disc.sessionId)) {
|
|
614
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
615
|
+
return pid;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (info.cwd === sessionCwd) {
|
|
619
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
620
|
+
return pid;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Check persisted metadata for detached processes
|
|
624
|
+
const meta = await this.readSessionMeta(disc.sessionId);
|
|
625
|
+
if (meta?.pid && this.isProcessAlive(meta.pid)) {
|
|
626
|
+
return meta.pid;
|
|
627
|
+
}
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
/** Parse session tail for model, tokens, and cost aggregation */
|
|
631
|
+
async parseSessionTail(filePath, header) {
|
|
632
|
+
try {
|
|
633
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
634
|
+
const lines = content.trim().split("\n");
|
|
635
|
+
let model = header.modelId;
|
|
636
|
+
let totalIn = 0;
|
|
637
|
+
let totalOut = 0;
|
|
638
|
+
let totalCost = 0;
|
|
639
|
+
for (const line of lines) {
|
|
640
|
+
try {
|
|
641
|
+
const entry = JSON.parse(line);
|
|
642
|
+
if (entry.type === "message") {
|
|
643
|
+
const msg = entry;
|
|
644
|
+
const payload = msg.message;
|
|
645
|
+
if (payload?.role === "assistant" && payload.usage) {
|
|
646
|
+
totalIn += payload.usage.input || 0;
|
|
647
|
+
totalOut += payload.usage.output || 0;
|
|
648
|
+
// Pi cost can be a number or an object with a total field
|
|
649
|
+
const rawCost = payload.usage.cost;
|
|
650
|
+
if (typeof rawCost === "number") {
|
|
651
|
+
totalCost += rawCost;
|
|
652
|
+
}
|
|
653
|
+
else if (rawCost && typeof rawCost === "object") {
|
|
654
|
+
totalCost += rawCost.total || 0;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
else if (entry.type === "model_change") {
|
|
659
|
+
model = entry.modelId;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// skip
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
model,
|
|
668
|
+
tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
|
|
669
|
+
cost: totalCost || undefined,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return { model: header.modelId };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/** Get the first user prompt from a session JSONL file */
|
|
677
|
+
async getFirstPrompt(filePath) {
|
|
678
|
+
try {
|
|
679
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
680
|
+
for (const line of content.split("\n").slice(0, 20)) {
|
|
681
|
+
try {
|
|
682
|
+
const entry = JSON.parse(line);
|
|
683
|
+
if (entry.type === "message") {
|
|
684
|
+
const msg = entry;
|
|
685
|
+
const payload = msg.message;
|
|
686
|
+
if (payload?.role === "user" && payload.content) {
|
|
687
|
+
const text = extractContent(payload.content);
|
|
688
|
+
return text?.slice(0, 200);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// skip
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// skip
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
/** Find a session by exact or prefix ID match */
|
|
703
|
+
async findSession(sessionId) {
|
|
704
|
+
const all = await this.discoverSessions();
|
|
705
|
+
return (all.find((d) => d.sessionId === sessionId || d.sessionId.startsWith(sessionId)) || null);
|
|
706
|
+
}
|
|
707
|
+
async findPidForSession(sessionId) {
|
|
708
|
+
const session = await this.status(sessionId);
|
|
709
|
+
return session.pid ?? null;
|
|
710
|
+
}
|
|
711
|
+
// --- Session metadata persistence ---
|
|
712
|
+
/** Write session metadata to disk so status checks survive wrapper exit */
|
|
713
|
+
async writeSessionMeta(meta) {
|
|
714
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
715
|
+
// Try to capture the process start time immediately
|
|
716
|
+
let startTime;
|
|
717
|
+
try {
|
|
718
|
+
const { stdout } = await execFileAsync("ps", [
|
|
719
|
+
"-p",
|
|
720
|
+
meta.pid.toString(),
|
|
721
|
+
"-o",
|
|
722
|
+
"lstart=",
|
|
723
|
+
]);
|
|
724
|
+
startTime = stdout.trim() || undefined;
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
// Process may have already exited or ps failed
|
|
728
|
+
}
|
|
729
|
+
const fullMeta = { ...meta, startTime };
|
|
730
|
+
const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
|
|
731
|
+
await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
|
|
732
|
+
}
|
|
733
|
+
/** Read persisted session metadata */
|
|
734
|
+
async readSessionMeta(sessionId) {
|
|
735
|
+
// Check exact sessionId first
|
|
736
|
+
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
|
|
737
|
+
try {
|
|
738
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
739
|
+
return JSON.parse(raw);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// File doesn't exist or is unreadable
|
|
743
|
+
}
|
|
744
|
+
// Scan all metadata files for one whose sessionId matches
|
|
745
|
+
try {
|
|
746
|
+
const files = await fs.readdir(this.sessionsMetaDir);
|
|
747
|
+
for (const file of files) {
|
|
748
|
+
if (!file.endsWith(".json"))
|
|
749
|
+
continue;
|
|
750
|
+
try {
|
|
751
|
+
const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
|
|
752
|
+
const m = JSON.parse(raw);
|
|
753
|
+
if (m.sessionId === sessionId)
|
|
754
|
+
return m;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// skip
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// Dir doesn't exist
|
|
763
|
+
}
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
/** Delete stale session metadata */
|
|
767
|
+
async deleteSessionMeta(sessionId) {
|
|
768
|
+
for (const id of [sessionId, `pending-${sessionId}`]) {
|
|
769
|
+
const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
|
|
770
|
+
try {
|
|
771
|
+
await fs.unlink(metaPath);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// File doesn't exist
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// --- Utility functions ---
|
|
780
|
+
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
781
|
+
function defaultIsProcessAlive(pid) {
|
|
782
|
+
try {
|
|
783
|
+
process.kill(pid, 0);
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/** Discover running pi processes via `ps aux` */
|
|
791
|
+
async function getPiPids() {
|
|
792
|
+
const pids = new Map();
|
|
793
|
+
try {
|
|
794
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
795
|
+
for (const line of stdout.split("\n")) {
|
|
796
|
+
if (line.includes("grep"))
|
|
797
|
+
continue;
|
|
798
|
+
// Extract PID (second field) and command (everything after 10th field)
|
|
799
|
+
const fields = line.trim().split(/\s+/);
|
|
800
|
+
if (fields.length < 11)
|
|
801
|
+
continue;
|
|
802
|
+
const pid = parseInt(fields[1], 10);
|
|
803
|
+
const command = fields.slice(10).join(" ");
|
|
804
|
+
// Match 'pi' command invocations with flags (e.g. "pi -p", "pi --json")
|
|
805
|
+
// Avoid matching other commands that happen to contain "pi"
|
|
806
|
+
if (!command.startsWith("pi -") && !command.startsWith("pi --"))
|
|
807
|
+
continue;
|
|
808
|
+
if (pid === process.pid)
|
|
809
|
+
continue;
|
|
810
|
+
// Try to extract working directory from lsof
|
|
811
|
+
let cwd = "";
|
|
812
|
+
try {
|
|
813
|
+
const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
|
|
814
|
+
"-p",
|
|
815
|
+
pid.toString(),
|
|
816
|
+
"-Fn",
|
|
817
|
+
]);
|
|
818
|
+
const lsofLines = lsofOut.split("\n");
|
|
819
|
+
for (let i = 0; i < lsofLines.length; i++) {
|
|
820
|
+
if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
|
|
821
|
+
cwd = lsofLines[i + 1].slice(1);
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// lsof might fail — that's fine
|
|
828
|
+
}
|
|
829
|
+
// Get process start time for PID recycling detection
|
|
830
|
+
let startTime;
|
|
831
|
+
try {
|
|
832
|
+
const { stdout: lstart } = await execFileAsync("ps", [
|
|
833
|
+
"-p",
|
|
834
|
+
pid.toString(),
|
|
835
|
+
"-o",
|
|
836
|
+
"lstart=",
|
|
837
|
+
]);
|
|
838
|
+
startTime = lstart.trim() || undefined;
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// ps might fail — that's fine
|
|
842
|
+
}
|
|
843
|
+
pids.set(pid, { pid, cwd, args: command, startTime });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// ps failed — return empty
|
|
848
|
+
}
|
|
849
|
+
return pids;
|
|
850
|
+
}
|
|
851
|
+
/** Extract text content from Pi message content field */
|
|
852
|
+
function extractContent(content) {
|
|
853
|
+
if (typeof content === "string")
|
|
854
|
+
return content;
|
|
855
|
+
if (Array.isArray(content)) {
|
|
856
|
+
return content
|
|
857
|
+
.filter((b) => b.type === "text" && b.text)
|
|
858
|
+
.map((b) => b.text)
|
|
859
|
+
.join("\n");
|
|
860
|
+
}
|
|
861
|
+
return "";
|
|
862
|
+
}
|
|
863
|
+
function sleep(ms) {
|
|
864
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
865
|
+
}
|