@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,753 @@
|
|
|
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_SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
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 Rust adapter — reads session data directly from ~/.pi/agent/sessions/
|
|
16
|
+
* and cross-references with running PIDs. NEVER maintains its own registry.
|
|
17
|
+
*
|
|
18
|
+
* Pi Rust (pi-rust / pi_agent_rust) stores sessions as JSONL files organized
|
|
19
|
+
* by project directory. It also maintains a SQLite index for fast lookups,
|
|
20
|
+
* but we read JSONL directly for simplicity and testability.
|
|
21
|
+
*/
|
|
22
|
+
export class PiRustAdapter {
|
|
23
|
+
id = "pi-rust";
|
|
24
|
+
sessionDir;
|
|
25
|
+
sessionsMetaDir;
|
|
26
|
+
getPids;
|
|
27
|
+
isProcessAlive;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.sessionDir = opts?.sessionDir || DEFAULT_SESSION_DIR;
|
|
30
|
+
this.sessionsMetaDir =
|
|
31
|
+
opts?.sessionsMetaDir ||
|
|
32
|
+
path.join(os.homedir(), ".pi", "agentctl", "sessions");
|
|
33
|
+
this.getPids = opts?.getPids || getPiRustPids;
|
|
34
|
+
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
35
|
+
}
|
|
36
|
+
async list(opts) {
|
|
37
|
+
const runningPids = await this.getPids();
|
|
38
|
+
const sessions = [];
|
|
39
|
+
let projectDirs;
|
|
40
|
+
try {
|
|
41
|
+
const entries = await fs.readdir(this.sessionDir);
|
|
42
|
+
// Project dirs start with "--" (encoded paths)
|
|
43
|
+
projectDirs = entries.filter((e) => e.startsWith("--"));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
for (const projDir of projectDirs) {
|
|
49
|
+
const projPath = path.join(this.sessionDir, projDir);
|
|
50
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
51
|
+
if (!stat?.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
const projectCwd = decodeProjDir(projDir);
|
|
54
|
+
const sessionFiles = await this.getSessionFiles(projPath);
|
|
55
|
+
for (const file of sessionFiles) {
|
|
56
|
+
const filePath = path.join(projPath, file);
|
|
57
|
+
const header = await this.readSessionHeader(filePath);
|
|
58
|
+
if (!header)
|
|
59
|
+
continue;
|
|
60
|
+
const session = await this.buildSession(header, filePath, projectCwd, runningPids);
|
|
61
|
+
// Filter by status
|
|
62
|
+
if (opts?.status && session.status !== opts.status)
|
|
63
|
+
continue;
|
|
64
|
+
// If not --all, skip old stopped sessions
|
|
65
|
+
if (!opts?.all && session.status === "stopped") {
|
|
66
|
+
const age = Date.now() - session.startedAt.getTime();
|
|
67
|
+
if (age > STOPPED_SESSION_MAX_AGE_MS)
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Default: only show running sessions unless --all
|
|
71
|
+
if (!opts?.all &&
|
|
72
|
+
!opts?.status &&
|
|
73
|
+
session.status !== "running" &&
|
|
74
|
+
session.status !== "idle") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
sessions.push(session);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Sort: running first, then by most recent
|
|
81
|
+
sessions.sort((a, b) => {
|
|
82
|
+
if (a.status === "running" && b.status !== "running")
|
|
83
|
+
return -1;
|
|
84
|
+
if (b.status === "running" && a.status !== "running")
|
|
85
|
+
return 1;
|
|
86
|
+
return b.startedAt.getTime() - a.startedAt.getTime();
|
|
87
|
+
});
|
|
88
|
+
return sessions;
|
|
89
|
+
}
|
|
90
|
+
async peek(sessionId, opts) {
|
|
91
|
+
const lines = opts?.lines ?? 20;
|
|
92
|
+
const filePath = await this.findSessionFile(sessionId);
|
|
93
|
+
if (!filePath)
|
|
94
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
95
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
96
|
+
const jsonlLines = content.trim().split("\n");
|
|
97
|
+
const assistantMessages = [];
|
|
98
|
+
for (const line of jsonlLines) {
|
|
99
|
+
try {
|
|
100
|
+
const msg = JSON.parse(line);
|
|
101
|
+
if (msg.type === "message" &&
|
|
102
|
+
msg.message?.role === "assistant" &&
|
|
103
|
+
msg.message?.content) {
|
|
104
|
+
const text = extractTextContent(msg.message.content);
|
|
105
|
+
if (text)
|
|
106
|
+
assistantMessages.push(text);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// skip malformed lines
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Take last N messages
|
|
114
|
+
const recent = assistantMessages.slice(-lines);
|
|
115
|
+
return recent.join("\n---\n");
|
|
116
|
+
}
|
|
117
|
+
async status(sessionId) {
|
|
118
|
+
const runningPids = await this.getPids();
|
|
119
|
+
const filePath = await this.findSessionFile(sessionId);
|
|
120
|
+
if (!filePath)
|
|
121
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
122
|
+
const header = await this.readSessionHeader(filePath);
|
|
123
|
+
if (!header)
|
|
124
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
125
|
+
const projDir = path.basename(path.dirname(filePath));
|
|
126
|
+
const projectCwd = decodeProjDir(projDir);
|
|
127
|
+
return this.buildSession(header, filePath, projectCwd, runningPids);
|
|
128
|
+
}
|
|
129
|
+
async launch(opts) {
|
|
130
|
+
const args = ["--print", "--mode", "json", opts.prompt];
|
|
131
|
+
if (opts.model) {
|
|
132
|
+
args.unshift("--model", opts.model);
|
|
133
|
+
}
|
|
134
|
+
const env = buildSpawnEnv(undefined, opts.env);
|
|
135
|
+
const cwd = opts.cwd || process.cwd();
|
|
136
|
+
// Write stdout to a log file so we can extract the session ID
|
|
137
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
138
|
+
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
|
|
139
|
+
const logFd = await fs.open(logPath, "w");
|
|
140
|
+
const piRustPath = await resolveBinaryPath("pi-rust");
|
|
141
|
+
const child = spawn(piRustPath, args, {
|
|
142
|
+
cwd,
|
|
143
|
+
env,
|
|
144
|
+
stdio: ["ignore", logFd.fd, "ignore"],
|
|
145
|
+
detached: true,
|
|
146
|
+
});
|
|
147
|
+
child.on("error", (err) => {
|
|
148
|
+
console.error(`[pi-rust] spawn error: ${err.message}`);
|
|
149
|
+
});
|
|
150
|
+
child.unref();
|
|
151
|
+
const pid = child.pid;
|
|
152
|
+
const now = new Date();
|
|
153
|
+
await logFd.close();
|
|
154
|
+
// Try to extract the real session ID from the JSONL output
|
|
155
|
+
let resolvedSessionId;
|
|
156
|
+
if (pid) {
|
|
157
|
+
resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
|
|
158
|
+
}
|
|
159
|
+
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
160
|
+
// Persist session metadata so status checks work after wrapper exits
|
|
161
|
+
if (pid) {
|
|
162
|
+
await this.writeSessionMeta({
|
|
163
|
+
sessionId,
|
|
164
|
+
pid,
|
|
165
|
+
wrapperPid: process.pid,
|
|
166
|
+
cwd,
|
|
167
|
+
model: opts.model,
|
|
168
|
+
prompt: opts.prompt.slice(0, 200),
|
|
169
|
+
launchedAt: now.toISOString(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const session = {
|
|
173
|
+
id: sessionId,
|
|
174
|
+
adapter: this.id,
|
|
175
|
+
status: "running",
|
|
176
|
+
startedAt: now,
|
|
177
|
+
cwd,
|
|
178
|
+
model: opts.model,
|
|
179
|
+
prompt: opts.prompt.slice(0, 200),
|
|
180
|
+
pid,
|
|
181
|
+
meta: {
|
|
182
|
+
adapterOpts: opts.adapterOpts,
|
|
183
|
+
spec: opts.spec,
|
|
184
|
+
logPath,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
return session;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Poll the launch log file for up to `timeoutMs` to extract the real session ID.
|
|
191
|
+
* Pi Rust's JSONL output includes the session ID in the first line (type: "session").
|
|
192
|
+
*/
|
|
193
|
+
async pollForSessionId(logPath, pid, timeoutMs) {
|
|
194
|
+
const deadline = Date.now() + timeoutMs;
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
try {
|
|
197
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
198
|
+
for (const line of content.split("\n")) {
|
|
199
|
+
if (!line.trim())
|
|
200
|
+
continue;
|
|
201
|
+
try {
|
|
202
|
+
const msg = JSON.parse(line);
|
|
203
|
+
if (msg.type === "session" &&
|
|
204
|
+
msg.id &&
|
|
205
|
+
typeof msg.id === "string") {
|
|
206
|
+
return msg.id;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Not valid JSON yet
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// File may not exist yet
|
|
216
|
+
}
|
|
217
|
+
// Check if process is still alive
|
|
218
|
+
try {
|
|
219
|
+
process.kill(pid, 0);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
break; // Process died
|
|
223
|
+
}
|
|
224
|
+
await sleep(200);
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
async stop(sessionId, opts) {
|
|
229
|
+
const pid = await this.findPidForSession(sessionId);
|
|
230
|
+
if (!pid)
|
|
231
|
+
throw new Error(`No running process for session: ${sessionId}`);
|
|
232
|
+
if (opts?.force) {
|
|
233
|
+
process.kill(pid, "SIGINT");
|
|
234
|
+
await sleep(5000);
|
|
235
|
+
try {
|
|
236
|
+
process.kill(pid, "SIGKILL");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Already dead
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
process.kill(pid, "SIGTERM");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async resume(sessionId, message) {
|
|
247
|
+
const filePath = await this.findSessionFile(sessionId);
|
|
248
|
+
const session = filePath
|
|
249
|
+
? await this.status(sessionId).catch(() => null)
|
|
250
|
+
: null;
|
|
251
|
+
const cwd = session?.cwd || process.cwd();
|
|
252
|
+
// pi-rust --continue resumes the previous session, --session <path> for a specific one
|
|
253
|
+
const args = ["--print", "-p", message];
|
|
254
|
+
if (filePath) {
|
|
255
|
+
args.unshift("--session", filePath);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
args.unshift("--continue");
|
|
259
|
+
}
|
|
260
|
+
const piRustPath = await resolveBinaryPath("pi-rust");
|
|
261
|
+
const child = spawn(piRustPath, args, {
|
|
262
|
+
cwd,
|
|
263
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
264
|
+
detached: true,
|
|
265
|
+
});
|
|
266
|
+
child.on("error", (err) => {
|
|
267
|
+
console.error(`[pi-rust] resume spawn error: ${err.message}`);
|
|
268
|
+
});
|
|
269
|
+
child.unref();
|
|
270
|
+
}
|
|
271
|
+
async *events() {
|
|
272
|
+
let knownSessions = new Map();
|
|
273
|
+
const initial = await this.list({ all: true });
|
|
274
|
+
for (const s of initial) {
|
|
275
|
+
knownSessions.set(s.id, s);
|
|
276
|
+
}
|
|
277
|
+
const watcher = watch(this.sessionDir, { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
while (true) {
|
|
280
|
+
await sleep(5000);
|
|
281
|
+
const current = await this.list({ all: true });
|
|
282
|
+
const currentMap = new Map(current.map((s) => [s.id, s]));
|
|
283
|
+
for (const [id, session] of currentMap) {
|
|
284
|
+
const prev = knownSessions.get(id);
|
|
285
|
+
if (!prev) {
|
|
286
|
+
yield {
|
|
287
|
+
type: "session.started",
|
|
288
|
+
adapter: this.id,
|
|
289
|
+
sessionId: id,
|
|
290
|
+
session,
|
|
291
|
+
timestamp: new Date(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
else if (prev.status === "running" &&
|
|
295
|
+
session.status === "stopped") {
|
|
296
|
+
yield {
|
|
297
|
+
type: "session.stopped",
|
|
298
|
+
adapter: this.id,
|
|
299
|
+
sessionId: id,
|
|
300
|
+
session,
|
|
301
|
+
timestamp: new Date(),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
else if (prev.status === "running" && session.status === "idle") {
|
|
305
|
+
yield {
|
|
306
|
+
type: "session.idle",
|
|
307
|
+
adapter: this.id,
|
|
308
|
+
sessionId: id,
|
|
309
|
+
session,
|
|
310
|
+
timestamp: new Date(),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
knownSessions = currentMap;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
watcher.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// --- Private helpers ---
|
|
322
|
+
/** List .jsonl session files in a project directory */
|
|
323
|
+
async getSessionFiles(projPath) {
|
|
324
|
+
try {
|
|
325
|
+
const entries = await fs.readdir(projPath);
|
|
326
|
+
return entries.filter((e) => e.endsWith(".jsonl"));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Read and parse the session header (first line) from a JSONL file */
|
|
333
|
+
async readSessionHeader(filePath) {
|
|
334
|
+
try {
|
|
335
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
336
|
+
const firstLine = content.split("\n")[0];
|
|
337
|
+
if (!firstLine?.trim())
|
|
338
|
+
return null;
|
|
339
|
+
const parsed = JSON.parse(firstLine);
|
|
340
|
+
if (parsed.type !== "session")
|
|
341
|
+
return null;
|
|
342
|
+
return parsed;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Extract the session ID from a JSONL filename (e.g., "2026-02-22T16-29-54.096Z_feb70071.jsonl" → "feb70071") */
|
|
349
|
+
extractShortId(filename) {
|
|
350
|
+
// Format: {timestamp}_{shortId}.jsonl
|
|
351
|
+
const base = filename.replace(".jsonl", "");
|
|
352
|
+
const parts = base.split("_");
|
|
353
|
+
return parts[parts.length - 1];
|
|
354
|
+
}
|
|
355
|
+
async buildSession(header, filePath, projectCwd, runningPids) {
|
|
356
|
+
const isRunning = await this.isSessionRunning(header, projectCwd, runningPids);
|
|
357
|
+
const { model, tokens, cost } = await this.parseSessionTail(filePath);
|
|
358
|
+
const firstPrompt = await this.readFirstPrompt(filePath);
|
|
359
|
+
let fileStat;
|
|
360
|
+
try {
|
|
361
|
+
fileStat = await fs.stat(filePath);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// ignore
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
id: header.id,
|
|
368
|
+
adapter: this.id,
|
|
369
|
+
status: isRunning ? "running" : "stopped",
|
|
370
|
+
startedAt: new Date(header.timestamp),
|
|
371
|
+
stoppedAt: isRunning
|
|
372
|
+
? undefined
|
|
373
|
+
: fileStat
|
|
374
|
+
? new Date(Number(fileStat.mtimeMs))
|
|
375
|
+
: undefined,
|
|
376
|
+
cwd: header.cwd || projectCwd,
|
|
377
|
+
model: model || header.modelId,
|
|
378
|
+
prompt: firstPrompt?.slice(0, 200),
|
|
379
|
+
tokens,
|
|
380
|
+
cost: cost ?? undefined,
|
|
381
|
+
pid: isRunning
|
|
382
|
+
? await this.findMatchingPid(header, projectCwd, runningPids)
|
|
383
|
+
: undefined,
|
|
384
|
+
meta: {
|
|
385
|
+
provider: header.provider,
|
|
386
|
+
thinkingLevel: header.thinkingLevel,
|
|
387
|
+
projectDir: projectCwd,
|
|
388
|
+
sessionFile: filePath,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
async isSessionRunning(header, projectCwd, runningPids) {
|
|
393
|
+
const sessionCreated = new Date(header.timestamp).getTime();
|
|
394
|
+
// 1. Check running PIDs discovered via `ps aux`
|
|
395
|
+
for (const [, info] of runningPids) {
|
|
396
|
+
if (info.args.includes(header.id)) {
|
|
397
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
398
|
+
return true;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (info.cwd === projectCwd) {
|
|
402
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// 2. Check persisted session metadata
|
|
407
|
+
const meta = await this.readSessionMeta(header.id);
|
|
408
|
+
if (meta?.pid) {
|
|
409
|
+
if (this.isProcessAlive(meta.pid)) {
|
|
410
|
+
const pidInfo = runningPids.get(meta.pid);
|
|
411
|
+
if (pidInfo?.startTime && meta.startTime) {
|
|
412
|
+
const currentStartMs = new Date(pidInfo.startTime).getTime();
|
|
413
|
+
const recordedStartMs = new Date(meta.startTime).getTime();
|
|
414
|
+
if (!Number.isNaN(currentStartMs) &&
|
|
415
|
+
!Number.isNaN(recordedStartMs) &&
|
|
416
|
+
Math.abs(currentStartMs - recordedStartMs) > 5000) {
|
|
417
|
+
await this.deleteSessionMeta(header.id);
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (meta.startTime) {
|
|
422
|
+
const metaStartMs = new Date(meta.startTime).getTime();
|
|
423
|
+
const sessionMs = new Date(meta.launchedAt).getTime();
|
|
424
|
+
if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
await this.deleteSessionMeta(header.id);
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
await this.deleteSessionMeta(header.id);
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
processStartedAfterSession(info, sessionCreatedMs) {
|
|
437
|
+
if (!info.startTime)
|
|
438
|
+
return false;
|
|
439
|
+
const processStartMs = new Date(info.startTime).getTime();
|
|
440
|
+
if (Number.isNaN(processStartMs))
|
|
441
|
+
return false;
|
|
442
|
+
return processStartMs >= sessionCreatedMs - 5000;
|
|
443
|
+
}
|
|
444
|
+
async findMatchingPid(header, projectCwd, runningPids) {
|
|
445
|
+
const sessionCreated = new Date(header.timestamp).getTime();
|
|
446
|
+
for (const [pid, info] of runningPids) {
|
|
447
|
+
if (info.args.includes(header.id)) {
|
|
448
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
449
|
+
return pid;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (info.cwd === projectCwd) {
|
|
453
|
+
if (this.processStartedAfterSession(info, sessionCreated))
|
|
454
|
+
return pid;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const meta = await this.readSessionMeta(header.id);
|
|
458
|
+
if (meta?.pid && this.isProcessAlive(meta.pid)) {
|
|
459
|
+
return meta.pid;
|
|
460
|
+
}
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
async parseSessionTail(filePath) {
|
|
464
|
+
try {
|
|
465
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
466
|
+
const lines = content.trim().split("\n");
|
|
467
|
+
let model;
|
|
468
|
+
let totalIn = 0;
|
|
469
|
+
let totalOut = 0;
|
|
470
|
+
let totalCost = 0;
|
|
471
|
+
const tail = lines.slice(-100);
|
|
472
|
+
for (const line of tail) {
|
|
473
|
+
try {
|
|
474
|
+
const msg = JSON.parse(line);
|
|
475
|
+
if (msg.type === "message" && msg.message?.role === "assistant") {
|
|
476
|
+
if (msg.message.model)
|
|
477
|
+
model = msg.message.model;
|
|
478
|
+
if (msg.message.usage) {
|
|
479
|
+
totalIn += msg.message.usage.input || 0;
|
|
480
|
+
totalOut += msg.message.usage.output || 0;
|
|
481
|
+
if (msg.message.usage.cost?.total) {
|
|
482
|
+
totalCost += msg.message.usage.cost.total;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// skip
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!model) {
|
|
492
|
+
const head = lines.slice(0, 20);
|
|
493
|
+
for (const line of head) {
|
|
494
|
+
try {
|
|
495
|
+
const msg = JSON.parse(line);
|
|
496
|
+
if (msg.type === "message" &&
|
|
497
|
+
msg.message?.role === "assistant" &&
|
|
498
|
+
msg.message?.model) {
|
|
499
|
+
model = msg.message.model;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
// Also check session header for modelId
|
|
503
|
+
if (msg.type === "session" &&
|
|
504
|
+
msg.modelId) {
|
|
505
|
+
model = msg.modelId;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// skip
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
model,
|
|
515
|
+
tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
|
|
516
|
+
cost: totalCost || undefined,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return {};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Read the first user prompt from a JSONL session file */
|
|
524
|
+
async readFirstPrompt(filePath) {
|
|
525
|
+
try {
|
|
526
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
527
|
+
for (const line of content.split("\n").slice(0, 20)) {
|
|
528
|
+
try {
|
|
529
|
+
const msg = JSON.parse(line);
|
|
530
|
+
if (msg.type === "message" &&
|
|
531
|
+
msg.message?.role === "user" &&
|
|
532
|
+
msg.message?.content) {
|
|
533
|
+
return extractTextContent(msg.message.content);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// skip
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// skip
|
|
543
|
+
}
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
/** Find a session JSONL file by session ID (full or prefix match) */
|
|
547
|
+
async findSessionFile(sessionId) {
|
|
548
|
+
let projectDirs;
|
|
549
|
+
try {
|
|
550
|
+
const entries = await fs.readdir(this.sessionDir);
|
|
551
|
+
projectDirs = entries.filter((e) => e.startsWith("--"));
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
for (const projDir of projectDirs) {
|
|
557
|
+
const projPath = path.join(this.sessionDir, projDir);
|
|
558
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
559
|
+
if (!stat?.isDirectory())
|
|
560
|
+
continue;
|
|
561
|
+
const files = await this.getSessionFiles(projPath);
|
|
562
|
+
for (const file of files) {
|
|
563
|
+
const filePath = path.join(projPath, file);
|
|
564
|
+
const header = await this.readSessionHeader(filePath);
|
|
565
|
+
if (!header)
|
|
566
|
+
continue;
|
|
567
|
+
// Full match or prefix match
|
|
568
|
+
if (header.id === sessionId || header.id.startsWith(sessionId)) {
|
|
569
|
+
return filePath;
|
|
570
|
+
}
|
|
571
|
+
// Also check if the short ID in the filename matches
|
|
572
|
+
const shortId = this.extractShortId(file);
|
|
573
|
+
if (shortId === sessionId || sessionId.startsWith(shortId)) {
|
|
574
|
+
return filePath;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
async findPidForSession(sessionId) {
|
|
581
|
+
const session = await this.status(sessionId);
|
|
582
|
+
return session.pid ?? null;
|
|
583
|
+
}
|
|
584
|
+
// --- Session metadata persistence ---
|
|
585
|
+
async writeSessionMeta(meta) {
|
|
586
|
+
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
587
|
+
let startTime;
|
|
588
|
+
try {
|
|
589
|
+
const { stdout } = await execFileAsync("ps", [
|
|
590
|
+
"-p",
|
|
591
|
+
meta.pid.toString(),
|
|
592
|
+
"-o",
|
|
593
|
+
"lstart=",
|
|
594
|
+
]);
|
|
595
|
+
startTime = stdout.trim() || undefined;
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
// Process may have already exited or ps failed
|
|
599
|
+
}
|
|
600
|
+
const fullMeta = { ...meta, startTime };
|
|
601
|
+
const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
|
|
602
|
+
await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
|
|
603
|
+
}
|
|
604
|
+
async readSessionMeta(sessionId) {
|
|
605
|
+
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
|
|
606
|
+
try {
|
|
607
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
608
|
+
return JSON.parse(raw);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// File doesn't exist or is unreadable
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const files = await fs.readdir(this.sessionsMetaDir);
|
|
615
|
+
for (const file of files) {
|
|
616
|
+
if (!file.endsWith(".json"))
|
|
617
|
+
continue;
|
|
618
|
+
try {
|
|
619
|
+
const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
|
|
620
|
+
const meta = JSON.parse(raw);
|
|
621
|
+
if (meta.sessionId === sessionId)
|
|
622
|
+
return meta;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
// skip
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// Dir doesn't exist
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
async deleteSessionMeta(sessionId) {
|
|
635
|
+
for (const id of [sessionId, `pending-${sessionId}`]) {
|
|
636
|
+
const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
|
|
637
|
+
try {
|
|
638
|
+
await fs.unlink(metaPath);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// File doesn't exist
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// --- Utility functions ---
|
|
647
|
+
/**
|
|
648
|
+
* Decode a Pi Rust project directory name back to the original path.
|
|
649
|
+
* Pi Rust encodes paths: "/" → "-", wrapped in "--".
|
|
650
|
+
* E.g., "--private-tmp-test-pi-rust--" → "/private/tmp/test-pi-rust"
|
|
651
|
+
*
|
|
652
|
+
* Note: This is a lossy encoding — hyphens in the original path are
|
|
653
|
+
* indistinguishable from path separators. We do our best to reconstruct.
|
|
654
|
+
*/
|
|
655
|
+
export function decodeProjDir(dirName) {
|
|
656
|
+
// Strip leading/trailing "--"
|
|
657
|
+
let inner = dirName;
|
|
658
|
+
if (inner.startsWith("--"))
|
|
659
|
+
inner = inner.slice(2);
|
|
660
|
+
if (inner.endsWith("--"))
|
|
661
|
+
inner = inner.slice(0, -2);
|
|
662
|
+
// Replace "-" with "/"
|
|
663
|
+
return `/${inner.replace(/-/g, "/")}`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Encode a path as a Pi Rust project directory name.
|
|
667
|
+
* E.g., "/private/tmp/test-pi-rust" → "--private-tmp-test-pi-rust--"
|
|
668
|
+
*/
|
|
669
|
+
export function encodeProjDir(cwdPath) {
|
|
670
|
+
// Strip leading "/" and replace remaining "/" and "-" with "-"
|
|
671
|
+
const stripped = cwdPath.startsWith("/") ? cwdPath.slice(1) : cwdPath;
|
|
672
|
+
const encoded = stripped.replace(/\//g, "-");
|
|
673
|
+
return `--${encoded}--`;
|
|
674
|
+
}
|
|
675
|
+
function defaultIsProcessAlive(pid) {
|
|
676
|
+
try {
|
|
677
|
+
process.kill(pid, 0);
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function getPiRustPids() {
|
|
685
|
+
const pids = new Map();
|
|
686
|
+
try {
|
|
687
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
688
|
+
for (const line of stdout.split("\n")) {
|
|
689
|
+
if (!line.includes("pi-rust") || line.includes("grep"))
|
|
690
|
+
continue;
|
|
691
|
+
const fields = line.trim().split(/\s+/);
|
|
692
|
+
if (fields.length < 11)
|
|
693
|
+
continue;
|
|
694
|
+
const pid = parseInt(fields[1], 10);
|
|
695
|
+
const command = fields.slice(10).join(" ");
|
|
696
|
+
// Match pi-rust processes (the binary, not wrappers)
|
|
697
|
+
if (!command.includes("pi-rust"))
|
|
698
|
+
continue;
|
|
699
|
+
if (pid === process.pid)
|
|
700
|
+
continue;
|
|
701
|
+
let cwd = "";
|
|
702
|
+
try {
|
|
703
|
+
const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
|
|
704
|
+
"-p",
|
|
705
|
+
pid.toString(),
|
|
706
|
+
"-Fn",
|
|
707
|
+
]);
|
|
708
|
+
const lsofLines = lsofOut.split("\n");
|
|
709
|
+
for (let i = 0; i < lsofLines.length; i++) {
|
|
710
|
+
if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
|
|
711
|
+
cwd = lsofLines[i + 1].slice(1);
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// lsof might fail
|
|
718
|
+
}
|
|
719
|
+
let startTime;
|
|
720
|
+
try {
|
|
721
|
+
const { stdout: lstart } = await execFileAsync("ps", [
|
|
722
|
+
"-p",
|
|
723
|
+
pid.toString(),
|
|
724
|
+
"-o",
|
|
725
|
+
"lstart=",
|
|
726
|
+
]);
|
|
727
|
+
startTime = lstart.trim() || undefined;
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
// ps might fail
|
|
731
|
+
}
|
|
732
|
+
pids.set(pid, { pid, cwd, args: command, startTime });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// ps failed — return empty
|
|
737
|
+
}
|
|
738
|
+
return pids;
|
|
739
|
+
}
|
|
740
|
+
function extractTextContent(content) {
|
|
741
|
+
if (typeof content === "string")
|
|
742
|
+
return content;
|
|
743
|
+
if (Array.isArray(content)) {
|
|
744
|
+
return content
|
|
745
|
+
.filter((b) => b.type === "text" && b.text)
|
|
746
|
+
.map((b) => b.text)
|
|
747
|
+
.join("\n");
|
|
748
|
+
}
|
|
749
|
+
return "";
|
|
750
|
+
}
|
|
751
|
+
function sleep(ms) {
|
|
752
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
753
|
+
}
|