@orgloop/agentctl 1.2.0 → 1.3.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/dist/adapters/claude-code.d.ts +3 -1
- package/dist/adapters/claude-code.js +65 -3
- package/dist/adapters/codex.d.ts +3 -1
- package/dist/adapters/codex.js +48 -3
- package/dist/adapters/openclaw.d.ts +3 -1
- package/dist/adapters/openclaw.js +61 -4
- package/dist/adapters/opencode.d.ts +3 -1
- package/dist/adapters/opencode.js +70 -3
- package/dist/adapters/pi-rust.d.ts +3 -1
- package/dist/adapters/pi-rust.js +87 -3
- package/dist/adapters/pi.d.ts +3 -1
- package/dist/adapters/pi.js +51 -3
- package/dist/cli.js +110 -97
- package/dist/core/types.d.ts +26 -2
- package/dist/daemon/fuse-engine.d.ts +13 -10
- package/dist/daemon/fuse-engine.js +69 -46
- package/dist/daemon/metrics.d.ts +2 -3
- package/dist/daemon/metrics.js +4 -7
- package/dist/daemon/server.js +136 -21
- package/dist/daemon/session-tracker.d.ts +13 -0
- package/dist/daemon/session-tracker.js +102 -10
- package/dist/daemon/state.d.ts +12 -2
- package/dist/hooks.d.ts +1 -1
- package/dist/utils/daemon-env.d.ts +16 -0
- package/dist/utils/daemon-env.js +85 -0
- package/dist/utils/resolve-binary.d.ts +14 -0
- package/dist/utils/resolve-binary.js +66 -0
- package/package.json +1 -1
- package/dist/merge.d.ts +0 -24
- package/dist/merge.js +0 -65
package/dist/daemon/metrics.js
CHANGED
|
@@ -5,8 +5,7 @@ export class MetricsRegistry {
|
|
|
5
5
|
sessionsTotalCompleted = 0;
|
|
6
6
|
sessionsTotalFailed = 0;
|
|
7
7
|
sessionsTotalStopped = 0;
|
|
8
|
-
|
|
9
|
-
clustersDeletedTotal = 0;
|
|
8
|
+
fusesExpiredTotal = 0;
|
|
10
9
|
sessionDurations = []; // seconds
|
|
11
10
|
constructor(sessionTracker, lockManager, fuseEngine) {
|
|
12
11
|
this.sessionTracker = sessionTracker;
|
|
@@ -28,9 +27,8 @@ export class MetricsRegistry {
|
|
|
28
27
|
if (durationSeconds != null)
|
|
29
28
|
this.sessionDurations.push(durationSeconds);
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
this.
|
|
33
|
-
this.clustersDeletedTotal++;
|
|
30
|
+
recordFuseExpired() {
|
|
31
|
+
this.fusesExpiredTotal++;
|
|
34
32
|
}
|
|
35
33
|
generateMetrics() {
|
|
36
34
|
const lines = [];
|
|
@@ -54,8 +52,7 @@ export class MetricsRegistry {
|
|
|
54
52
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalCompleted, 'status="completed"');
|
|
55
53
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalFailed, 'status="failed"');
|
|
56
54
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalStopped, 'status="stopped"');
|
|
57
|
-
c("
|
|
58
|
-
c("agentctl_kind_clusters_deleted_total", "Total Kind clusters deleted", this.clustersDeletedTotal);
|
|
55
|
+
c("agentctl_fuses_expired_total", "Total fuses expired", this.fusesExpiredTotal);
|
|
59
56
|
// Histogram (session duration)
|
|
60
57
|
lines.push("# HELP agentctl_session_duration_seconds Session duration histogram");
|
|
61
58
|
lines.push("# TYPE agentctl_session_duration_seconds histogram");
|
package/dist/daemon/server.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import http from "node:http";
|
|
4
5
|
import net from "node:net";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
7
9
|
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
8
10
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
9
11
|
import { OpenClawAdapter } from "../adapters/openclaw.js";
|
|
@@ -11,29 +13,38 @@ import { OpenCodeAdapter } from "../adapters/opencode.js";
|
|
|
11
13
|
import { PiAdapter } from "../adapters/pi.js";
|
|
12
14
|
import { PiRustAdapter } from "../adapters/pi-rust.js";
|
|
13
15
|
import { migrateLocks } from "../migration/migrate-locks.js";
|
|
16
|
+
import { saveEnvironment } from "../utils/daemon-env.js";
|
|
17
|
+
import { clearBinaryCache } from "../utils/resolve-binary.js";
|
|
14
18
|
import { FuseEngine } from "./fuse-engine.js";
|
|
15
19
|
import { LockManager } from "./lock-manager.js";
|
|
16
20
|
import { MetricsRegistry } from "./metrics.js";
|
|
17
21
|
import { SessionTracker } from "./session-tracker.js";
|
|
18
22
|
import { StateManager } from "./state.js";
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
19
24
|
const startTime = Date.now();
|
|
20
25
|
export async function startDaemon(opts = {}) {
|
|
21
26
|
const configDir = opts.configDir || path.join(os.homedir(), ".agentctl");
|
|
22
27
|
await fs.mkdir(configDir, { recursive: true });
|
|
23
|
-
// 1. Check for existing daemon
|
|
24
28
|
const pidFilePath = path.join(configDir, "agentctl.pid");
|
|
25
|
-
const existingPid = await readPidFile(pidFilePath);
|
|
26
|
-
if (existingPid && isProcessAlive(existingPid)) {
|
|
27
|
-
throw new Error(`Daemon already running (PID ${existingPid})`);
|
|
28
|
-
}
|
|
29
|
-
// 2. Clean stale socket
|
|
30
29
|
const sockPath = path.join(configDir, "agentctl.sock");
|
|
30
|
+
// 1. Kill stale daemon/supervisor processes before anything else (#39)
|
|
31
|
+
await killStaleDaemons(configDir);
|
|
32
|
+
// 2. Verify no daemon is actually running by trying to connect to socket
|
|
33
|
+
const socketAlive = await isSocketAlive(sockPath);
|
|
34
|
+
if (socketAlive) {
|
|
35
|
+
throw new Error("Daemon already running (socket responsive)");
|
|
36
|
+
}
|
|
37
|
+
// 3. Clean stale socket file
|
|
31
38
|
await fs.rm(sockPath, { force: true });
|
|
32
|
-
//
|
|
39
|
+
// 4. Save shell environment for subprocess spawning (#42)
|
|
40
|
+
await saveEnvironment(configDir);
|
|
41
|
+
// 5. Clear binary cache on restart (#41 — pick up moved/updated binaries)
|
|
42
|
+
clearBinaryCache();
|
|
43
|
+
// 6. Run migration (idempotent)
|
|
33
44
|
await migrateLocks(configDir).catch((err) => console.error("Migration warning:", err.message));
|
|
34
|
-
//
|
|
45
|
+
// 7. Load persisted state
|
|
35
46
|
const state = await StateManager.load(configDir);
|
|
36
|
-
//
|
|
47
|
+
// 8. Initialize subsystems
|
|
37
48
|
const adapters = opts.adapters || {
|
|
38
49
|
"claude-code": new ClaudeCodeAdapter(),
|
|
39
50
|
codex: new CodexAdapter(),
|
|
@@ -51,14 +62,16 @@ export async function startDaemon(opts = {}) {
|
|
|
51
62
|
const sessionTracker = new SessionTracker(state, { adapters });
|
|
52
63
|
const metrics = new MetricsRegistry(sessionTracker, lockManager, fuseEngine);
|
|
53
64
|
// Wire up events
|
|
54
|
-
emitter.on("fuse.
|
|
55
|
-
metrics.
|
|
65
|
+
emitter.on("fuse.expired", () => {
|
|
66
|
+
metrics.recordFuseExpired();
|
|
56
67
|
});
|
|
57
|
-
//
|
|
68
|
+
// 9. Validate all sessions on startup — mark dead ones as stopped (#40)
|
|
69
|
+
sessionTracker.validateAllSessions();
|
|
70
|
+
// 10. Resume fuse timers
|
|
58
71
|
fuseEngine.resumeTimers();
|
|
59
|
-
//
|
|
72
|
+
// 11. Start session polling
|
|
60
73
|
sessionTracker.startPolling();
|
|
61
|
-
//
|
|
74
|
+
// 12. Create request handler
|
|
62
75
|
const handleRequest = createRequestHandler({
|
|
63
76
|
sessionTracker,
|
|
64
77
|
lockManager,
|
|
@@ -69,7 +82,7 @@ export async function startDaemon(opts = {}) {
|
|
|
69
82
|
configDir,
|
|
70
83
|
sockPath,
|
|
71
84
|
});
|
|
72
|
-
//
|
|
85
|
+
// 13. Start Unix socket server
|
|
73
86
|
const socketServer = net.createServer((conn) => {
|
|
74
87
|
let buffer = "";
|
|
75
88
|
conn.on("data", (chunk) => {
|
|
@@ -105,7 +118,9 @@ export async function startDaemon(opts = {}) {
|
|
|
105
118
|
socketServer.listen(sockPath, () => resolve());
|
|
106
119
|
socketServer.on("error", reject);
|
|
107
120
|
});
|
|
108
|
-
//
|
|
121
|
+
// 14. Write PID file (after socket is listening — acts as "lock acquired")
|
|
122
|
+
await fs.writeFile(pidFilePath, String(process.pid));
|
|
123
|
+
// 15. Start HTTP metrics server
|
|
109
124
|
const metricsPort = opts.metricsPort ?? 9200;
|
|
110
125
|
const httpServer = http.createServer((req, res) => {
|
|
111
126
|
if (req.url === "/metrics" && req.method === "GET") {
|
|
@@ -123,8 +138,6 @@ export async function startDaemon(opts = {}) {
|
|
|
123
138
|
httpServer.listen(metricsPort, "127.0.0.1", () => resolve());
|
|
124
139
|
httpServer.on("error", reject);
|
|
125
140
|
});
|
|
126
|
-
// 11. Write PID file
|
|
127
|
-
await fs.writeFile(pidFilePath, String(process.pid));
|
|
128
141
|
// Shutdown function
|
|
129
142
|
const shutdown = async () => {
|
|
130
143
|
sessionTracker.stopPolling();
|
|
@@ -136,7 +149,7 @@ export async function startDaemon(opts = {}) {
|
|
|
136
149
|
await fs.rm(sockPath, { force: true });
|
|
137
150
|
await fs.rm(pidFilePath, { force: true });
|
|
138
151
|
};
|
|
139
|
-
//
|
|
152
|
+
// 16. Signal handlers
|
|
140
153
|
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
141
154
|
process.on(sig, async () => {
|
|
142
155
|
console.log(`Received ${sig}, shutting down...`);
|
|
@@ -149,6 +162,86 @@ export async function startDaemon(opts = {}) {
|
|
|
149
162
|
console.log(` Metrics: http://localhost:${metricsPort}/metrics`);
|
|
150
163
|
return { socketServer, httpServer, shutdown };
|
|
151
164
|
}
|
|
165
|
+
// --- Stale daemon cleanup (#39) ---
|
|
166
|
+
/**
|
|
167
|
+
* Find and kill ALL stale agentctl daemon/supervisor processes.
|
|
168
|
+
* This ensures singleton enforcement even after unclean shutdowns.
|
|
169
|
+
*/
|
|
170
|
+
async function killStaleDaemons(configDir) {
|
|
171
|
+
// 1. Kill processes recorded in PID files
|
|
172
|
+
for (const pidFile of ["agentctl.pid", "supervisor.pid"]) {
|
|
173
|
+
const p = path.join(configDir, pidFile);
|
|
174
|
+
const pid = await readPidFile(p);
|
|
175
|
+
if (pid && pid !== process.pid && isProcessAlive(pid)) {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, "SIGTERM");
|
|
178
|
+
// Wait briefly for clean shutdown
|
|
179
|
+
await sleep(500);
|
|
180
|
+
if (isProcessAlive(pid)) {
|
|
181
|
+
process.kill(pid, "SIGKILL");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Already gone
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Clean up stale PID file
|
|
189
|
+
await fs.rm(p, { force: true }).catch(() => { });
|
|
190
|
+
}
|
|
191
|
+
// 2. Scan ps for any remaining agentctl daemon processes
|
|
192
|
+
try {
|
|
193
|
+
const { stdout } = await execFileAsync("ps", ["aux"]);
|
|
194
|
+
for (const line of stdout.split("\n")) {
|
|
195
|
+
if (!line.includes("agentctl") || !line.includes("daemon"))
|
|
196
|
+
continue;
|
|
197
|
+
if (line.includes("grep"))
|
|
198
|
+
continue;
|
|
199
|
+
const fields = line.trim().split(/\s+/);
|
|
200
|
+
if (fields.length < 2)
|
|
201
|
+
continue;
|
|
202
|
+
const pid = Number.parseInt(fields[1], 10);
|
|
203
|
+
if (Number.isNaN(pid) || pid === process.pid)
|
|
204
|
+
continue;
|
|
205
|
+
// Also skip our parent process (supervisor)
|
|
206
|
+
if (pid === process.ppid)
|
|
207
|
+
continue;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, "SIGTERM");
|
|
210
|
+
await sleep(200);
|
|
211
|
+
if (isProcessAlive(pid)) {
|
|
212
|
+
process.kill(pid, "SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Already gone
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ps failed — best effort
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check if a Unix socket is actually responsive (not just a stale file).
|
|
226
|
+
*/
|
|
227
|
+
async function isSocketAlive(sockPath) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const socket = net.createConnection(sockPath);
|
|
230
|
+
const timeout = setTimeout(() => {
|
|
231
|
+
socket.destroy();
|
|
232
|
+
resolve(false);
|
|
233
|
+
}, 1000);
|
|
234
|
+
socket.on("connect", () => {
|
|
235
|
+
clearTimeout(timeout);
|
|
236
|
+
socket.destroy();
|
|
237
|
+
resolve(true);
|
|
238
|
+
});
|
|
239
|
+
socket.on("error", () => {
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
resolve(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
152
245
|
function createRequestHandler(ctx) {
|
|
153
246
|
return async (req) => {
|
|
154
247
|
const params = (req.params || {});
|
|
@@ -242,10 +335,9 @@ function createRequestHandler(ctx) {
|
|
|
242
335
|
});
|
|
243
336
|
// Remove auto-lock
|
|
244
337
|
ctx.lockManager.autoUnlock(session.id);
|
|
245
|
-
// Mark stopped
|
|
338
|
+
// Mark stopped
|
|
246
339
|
const stopped = ctx.sessionTracker.onSessionExit(session.id);
|
|
247
340
|
if (stopped) {
|
|
248
|
-
ctx.fuseEngine.onSessionExit(stopped);
|
|
249
341
|
ctx.metrics.recordSessionStopped();
|
|
250
342
|
}
|
|
251
343
|
return null;
|
|
@@ -260,6 +352,11 @@ function createRequestHandler(ctx) {
|
|
|
260
352
|
await adapter.resume(session.id, params.message);
|
|
261
353
|
return null;
|
|
262
354
|
}
|
|
355
|
+
// --- Prune command (#40) ---
|
|
356
|
+
case "session.prune": {
|
|
357
|
+
const pruned = ctx.sessionTracker.pruneDeadSessions();
|
|
358
|
+
return { pruned };
|
|
359
|
+
}
|
|
263
360
|
case "lock.list":
|
|
264
361
|
return ctx.lockManager.listAll();
|
|
265
362
|
case "lock.acquire":
|
|
@@ -269,6 +366,21 @@ function createRequestHandler(ctx) {
|
|
|
269
366
|
return null;
|
|
270
367
|
case "fuse.list":
|
|
271
368
|
return ctx.fuseEngine.listActive();
|
|
369
|
+
case "fuse.set":
|
|
370
|
+
ctx.fuseEngine.setFuse({
|
|
371
|
+
directory: params.directory,
|
|
372
|
+
sessionId: params.sessionId,
|
|
373
|
+
ttlMs: params.ttlMs,
|
|
374
|
+
onExpire: params.onExpire,
|
|
375
|
+
label: params.label,
|
|
376
|
+
});
|
|
377
|
+
return null;
|
|
378
|
+
case "fuse.extend": {
|
|
379
|
+
const extended = ctx.fuseEngine.extendFuse(params.directory, params.ttlMs);
|
|
380
|
+
if (!extended)
|
|
381
|
+
throw new Error(`No active fuse for directory: ${params.directory}`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
272
384
|
case "fuse.cancel":
|
|
273
385
|
ctx.fuseEngine.cancelFuse(params.directory);
|
|
274
386
|
return null;
|
|
@@ -311,3 +423,6 @@ function isProcessAlive(pid) {
|
|
|
311
423
|
return false;
|
|
312
424
|
}
|
|
313
425
|
}
|
|
426
|
+
function sleep(ms) {
|
|
427
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
428
|
+
}
|
|
@@ -25,6 +25,19 @@ export declare class SessionTracker {
|
|
|
25
25
|
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
26
26
|
*/
|
|
27
27
|
private reapStaleEntries;
|
|
28
|
+
/**
|
|
29
|
+
* Validate all sessions on daemon startup (#40).
|
|
30
|
+
* Any session marked as "running" or "idle" whose PID is dead gets
|
|
31
|
+
* immediately marked as "stopped". This prevents unbounded growth of
|
|
32
|
+
* ghost sessions across daemon restarts.
|
|
33
|
+
*/
|
|
34
|
+
validateAllSessions(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Aggressively prune all clearly-dead sessions (#40).
|
|
37
|
+
* Returns the number of sessions pruned.
|
|
38
|
+
* Called via `agentctl prune` command.
|
|
39
|
+
*/
|
|
40
|
+
pruneDeadSessions(): number;
|
|
28
41
|
/**
|
|
29
42
|
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
30
43
|
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
@@ -42,24 +42,25 @@ export class SessionTracker {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
async poll() {
|
|
45
|
-
// Collect PIDs from all adapter-
|
|
45
|
+
// Collect PIDs from all adapter-discovered sessions (the source of truth)
|
|
46
46
|
const adapterPidToId = new Map();
|
|
47
47
|
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
48
48
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
// Discover-first: adapter.discover() is the ground truth
|
|
50
|
+
const discovered = await adapter.discover();
|
|
51
|
+
for (const disc of discovered) {
|
|
52
|
+
if (disc.pid) {
|
|
53
|
+
adapterPidToId.set(disc.pid, disc.id);
|
|
53
54
|
}
|
|
54
|
-
const existing = this.state.getSession(
|
|
55
|
-
const record =
|
|
55
|
+
const existing = this.state.getSession(disc.id);
|
|
56
|
+
const record = discoveredToRecord(disc, adapterName);
|
|
56
57
|
if (!existing) {
|
|
57
|
-
this.state.setSession(
|
|
58
|
+
this.state.setSession(disc.id, record);
|
|
58
59
|
}
|
|
59
60
|
else if (existing.status !== record.status ||
|
|
60
61
|
(!existing.model && record.model)) {
|
|
61
|
-
// Status changed or model resolved — update
|
|
62
|
-
this.state.setSession(
|
|
62
|
+
// Status changed or model resolved — update, preserving metadata
|
|
63
|
+
this.state.setSession(disc.id, {
|
|
63
64
|
...existing,
|
|
64
65
|
status: record.status,
|
|
65
66
|
stoppedAt: record.stoppedAt,
|
|
@@ -67,6 +68,7 @@ export class SessionTracker {
|
|
|
67
68
|
tokens: record.tokens,
|
|
68
69
|
cost: record.cost,
|
|
69
70
|
prompt: record.prompt || existing.prompt,
|
|
71
|
+
pid: record.pid,
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
}
|
|
@@ -113,6 +115,79 @@ export class SessionTracker {
|
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Validate all sessions on daemon startup (#40).
|
|
120
|
+
* Any session marked as "running" or "idle" whose PID is dead gets
|
|
121
|
+
* immediately marked as "stopped". This prevents unbounded growth of
|
|
122
|
+
* ghost sessions across daemon restarts.
|
|
123
|
+
*/
|
|
124
|
+
validateAllSessions() {
|
|
125
|
+
const sessions = this.state.getSessions();
|
|
126
|
+
let cleaned = 0;
|
|
127
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
128
|
+
if (record.status !== "running" && record.status !== "idle")
|
|
129
|
+
continue;
|
|
130
|
+
if (record.pid) {
|
|
131
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
132
|
+
this.state.setSession(id, {
|
|
133
|
+
...record,
|
|
134
|
+
status: "stopped",
|
|
135
|
+
stoppedAt: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
cleaned++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// No PID recorded — can't verify, mark as stopped
|
|
142
|
+
this.state.setSession(id, {
|
|
143
|
+
...record,
|
|
144
|
+
status: "stopped",
|
|
145
|
+
stoppedAt: new Date().toISOString(),
|
|
146
|
+
});
|
|
147
|
+
cleaned++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (cleaned > 0) {
|
|
151
|
+
console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Aggressively prune all clearly-dead sessions (#40).
|
|
156
|
+
* Returns the number of sessions pruned.
|
|
157
|
+
* Called via `agentctl prune` command.
|
|
158
|
+
*/
|
|
159
|
+
pruneDeadSessions() {
|
|
160
|
+
const sessions = this.state.getSessions();
|
|
161
|
+
let pruned = 0;
|
|
162
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
163
|
+
// Remove stopped/completed/failed sessions older than 24h
|
|
164
|
+
if (record.status === "stopped" ||
|
|
165
|
+
record.status === "completed" ||
|
|
166
|
+
record.status === "failed") {
|
|
167
|
+
const stoppedAt = record.stoppedAt
|
|
168
|
+
? new Date(record.stoppedAt).getTime()
|
|
169
|
+
: new Date(record.startedAt).getTime();
|
|
170
|
+
const age = Date.now() - stoppedAt;
|
|
171
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
172
|
+
this.state.removeSession(id);
|
|
173
|
+
pruned++;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Remove running/idle sessions whose PID is dead
|
|
178
|
+
if (record.status === "running" || record.status === "idle") {
|
|
179
|
+
if (record.pid && !this.isProcessAlive(record.pid)) {
|
|
180
|
+
this.state.removeSession(id);
|
|
181
|
+
pruned++;
|
|
182
|
+
}
|
|
183
|
+
else if (!record.pid) {
|
|
184
|
+
this.state.removeSession(id);
|
|
185
|
+
pruned++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return pruned;
|
|
190
|
+
}
|
|
116
191
|
/**
|
|
117
192
|
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
118
193
|
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
@@ -265,3 +340,20 @@ function sessionToRecord(session, adapterName) {
|
|
|
265
340
|
meta: session.meta,
|
|
266
341
|
};
|
|
267
342
|
}
|
|
343
|
+
/** Convert a DiscoveredSession (adapter ground truth) to a SessionRecord for state */
|
|
344
|
+
function discoveredToRecord(disc, adapterName) {
|
|
345
|
+
return {
|
|
346
|
+
id: disc.id,
|
|
347
|
+
adapter: adapterName,
|
|
348
|
+
status: disc.status,
|
|
349
|
+
startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
350
|
+
stoppedAt: disc.stoppedAt?.toISOString(),
|
|
351
|
+
cwd: disc.cwd,
|
|
352
|
+
model: disc.model,
|
|
353
|
+
prompt: disc.prompt,
|
|
354
|
+
tokens: disc.tokens,
|
|
355
|
+
cost: disc.cost,
|
|
356
|
+
pid: disc.pid,
|
|
357
|
+
meta: disc.nativeMetadata ?? {},
|
|
358
|
+
};
|
|
359
|
+
}
|
package/dist/daemon/state.d.ts
CHANGED
|
@@ -28,10 +28,20 @@ export interface Lock {
|
|
|
28
28
|
}
|
|
29
29
|
export interface FuseTimer {
|
|
30
30
|
directory: string;
|
|
31
|
-
|
|
32
|
-
branch: string;
|
|
31
|
+
ttlMs: number;
|
|
33
32
|
expiresAt: string;
|
|
34
33
|
sessionId: string;
|
|
34
|
+
/** On-expire action: shell command, webhook URL, or event name */
|
|
35
|
+
onExpire?: FuseAction;
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface FuseAction {
|
|
39
|
+
/** Shell script to run when fuse expires. CWD is the fuse directory. */
|
|
40
|
+
script?: string;
|
|
41
|
+
/** Webhook URL to POST to when fuse expires */
|
|
42
|
+
webhook?: string;
|
|
43
|
+
/** Event name to emit when fuse expires */
|
|
44
|
+
event?: string;
|
|
35
45
|
}
|
|
36
46
|
export interface PersistedState {
|
|
37
47
|
sessions: Record<string, SessionRecord>;
|
package/dist/hooks.d.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save the current process environment to disk.
|
|
3
|
+
* Called at daemon start time when we still have the user's shell env.
|
|
4
|
+
*/
|
|
5
|
+
export declare function saveEnvironment(configDir: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Load the saved environment from disk.
|
|
8
|
+
* Returns undefined if the env file doesn't exist or is corrupt.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadSavedEnvironment(configDir: string): Promise<Record<string, string> | undefined>;
|
|
11
|
+
/**
|
|
12
|
+
* Build an augmented environment for spawning subprocesses.
|
|
13
|
+
* Merges the saved daemon env with common bin paths to ensure
|
|
14
|
+
* binaries are findable even when the daemon is detached from the shell.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildSpawnEnv(savedEnv?: Record<string, string>, extraEnv?: Record<string, string>): Record<string, string>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
const ENV_FILE = "daemon-env.json";
|
|
5
|
+
/**
|
|
6
|
+
* Common bin directories that should be in PATH when spawning subprocesses.
|
|
7
|
+
* These cover the usual locations for various package managers and tools.
|
|
8
|
+
*/
|
|
9
|
+
function getCommonBinDirs() {
|
|
10
|
+
const home = os.homedir();
|
|
11
|
+
return [
|
|
12
|
+
path.join(home, ".local", "bin"),
|
|
13
|
+
"/usr/local/bin",
|
|
14
|
+
"/usr/bin",
|
|
15
|
+
"/bin",
|
|
16
|
+
"/usr/sbin",
|
|
17
|
+
"/sbin",
|
|
18
|
+
"/opt/homebrew/bin",
|
|
19
|
+
path.join(home, ".npm-global", "bin"),
|
|
20
|
+
path.join(home, ".local", "share", "mise", "shims"),
|
|
21
|
+
path.join(home, ".cargo", "bin"),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Save the current process environment to disk.
|
|
26
|
+
* Called at daemon start time when we still have the user's shell env.
|
|
27
|
+
*/
|
|
28
|
+
export async function saveEnvironment(configDir) {
|
|
29
|
+
const envPath = path.join(configDir, ENV_FILE);
|
|
30
|
+
try {
|
|
31
|
+
const tmpPath = `${envPath}.tmp`;
|
|
32
|
+
await fs.writeFile(tmpPath, JSON.stringify(process.env));
|
|
33
|
+
await fs.rename(tmpPath, envPath);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error(`Warning: could not save environment: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load the saved environment from disk.
|
|
41
|
+
* Returns undefined if the env file doesn't exist or is corrupt.
|
|
42
|
+
*/
|
|
43
|
+
export async function loadSavedEnvironment(configDir) {
|
|
44
|
+
const envPath = path.join(configDir, ENV_FILE);
|
|
45
|
+
try {
|
|
46
|
+
const raw = await fs.readFile(envPath, "utf-8");
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// File doesn't exist or is corrupt
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build an augmented environment for spawning subprocesses.
|
|
59
|
+
* Merges the saved daemon env with common bin paths to ensure
|
|
60
|
+
* binaries are findable even when the daemon is detached from the shell.
|
|
61
|
+
*/
|
|
62
|
+
export function buildSpawnEnv(savedEnv, extraEnv) {
|
|
63
|
+
const base = {};
|
|
64
|
+
const source = savedEnv || process.env;
|
|
65
|
+
// Copy source env
|
|
66
|
+
for (const [k, v] of Object.entries(source)) {
|
|
67
|
+
if (v !== undefined)
|
|
68
|
+
base[k] = v;
|
|
69
|
+
}
|
|
70
|
+
// Augment PATH with common bin directories
|
|
71
|
+
const existingPath = base.PATH || "";
|
|
72
|
+
const existingDirs = new Set(existingPath.split(":").filter(Boolean));
|
|
73
|
+
const commonDirs = getCommonBinDirs();
|
|
74
|
+
const newDirs = commonDirs.filter((d) => !existingDirs.has(d));
|
|
75
|
+
if (newDirs.length > 0) {
|
|
76
|
+
base.PATH = [...existingPath.split(":").filter(Boolean), ...newDirs].join(":");
|
|
77
|
+
}
|
|
78
|
+
// Apply extra env overrides
|
|
79
|
+
if (extraEnv) {
|
|
80
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
81
|
+
base[k] = v;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the absolute path to a binary, checking known locations first,
|
|
3
|
+
* then falling back to `which`. Results are cached per binary name.
|
|
4
|
+
*
|
|
5
|
+
* @param name - Binary name (e.g., "claude", "codex", "pi")
|
|
6
|
+
* @param knownLocations - Additional absolute paths to check first
|
|
7
|
+
* @returns Resolved absolute path, or bare name as last resort
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveBinaryPath(name: string, knownLocations?: string[]): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Clear the resolved path cache. Call this when binaries may have been
|
|
12
|
+
* updated (e.g., on daemon restart).
|
|
13
|
+
*/
|
|
14
|
+
export declare function clearBinaryCache(): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** Cache of resolved binary paths: name → absolute path */
|
|
8
|
+
const resolvedCache = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the absolute path to a binary, checking known locations first,
|
|
11
|
+
* then falling back to `which`. Results are cached per binary name.
|
|
12
|
+
*
|
|
13
|
+
* @param name - Binary name (e.g., "claude", "codex", "pi")
|
|
14
|
+
* @param knownLocations - Additional absolute paths to check first
|
|
15
|
+
* @returns Resolved absolute path, or bare name as last resort
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveBinaryPath(name, knownLocations = []) {
|
|
18
|
+
const cached = resolvedCache.get(name);
|
|
19
|
+
if (cached)
|
|
20
|
+
return cached;
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
// Default well-known locations for common toolchains
|
|
23
|
+
const defaultLocations = [
|
|
24
|
+
path.join(home, ".local", "bin", name),
|
|
25
|
+
`/usr/local/bin/${name}`,
|
|
26
|
+
`/opt/homebrew/bin/${name}`, // Homebrew Apple Silicon
|
|
27
|
+
path.join(home, ".npm-global", "bin", name),
|
|
28
|
+
path.join(home, ".local", "share", "mise", "shims", name),
|
|
29
|
+
path.join(home, ".cargo", "bin", name),
|
|
30
|
+
];
|
|
31
|
+
const candidates = [...knownLocations, ...defaultLocations];
|
|
32
|
+
for (const c of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(c, fs.constants.X_OK);
|
|
35
|
+
// Resolve symlinks to get the actual binary path
|
|
36
|
+
const resolved = await fs.realpath(c);
|
|
37
|
+
await fs.access(resolved, fs.constants.X_OK);
|
|
38
|
+
resolvedCache.set(name, resolved);
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Try next
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Try `which <name>` as fallback
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await execFileAsync("which", [name]);
|
|
48
|
+
const p = stdout.trim();
|
|
49
|
+
if (p) {
|
|
50
|
+
resolvedCache.set(name, p);
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Fall through
|
|
56
|
+
}
|
|
57
|
+
// Last resort: bare name (let PATH resolve it at spawn time)
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Clear the resolved path cache. Call this when binaries may have been
|
|
62
|
+
* updated (e.g., on daemon restart).
|
|
63
|
+
*/
|
|
64
|
+
export function clearBinaryCache() {
|
|
65
|
+
resolvedCache.clear();
|
|
66
|
+
}
|