@orgloop/agentctl 1.2.1 → 1.4.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 +51 -0
- package/dist/adapters/codex.d.ts +3 -1
- package/dist/adapters/codex.js +35 -0
- 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 +57 -0
- package/dist/adapters/pi-rust.d.ts +3 -1
- package/dist/adapters/pi-rust.js +74 -0
- package/dist/adapters/pi.d.ts +3 -1
- package/dist/adapters/pi.js +38 -0
- package/dist/cli.js +55 -96
- 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 +8 -6
- package/dist/daemon/metrics.js +15 -11
- package/dist/daemon/server.js +159 -43
- package/dist/daemon/session-tracker.d.ts +42 -43
- package/dist/daemon/session-tracker.js +141 -255
- package/dist/daemon/state.d.ts +12 -2
- package/dist/hooks.d.ts +1 -1
- package/package.json +1 -1
- package/dist/merge.d.ts +0 -24
- package/dist/merge.js +0 -65
package/dist/daemon/server.js
CHANGED
|
@@ -60,17 +60,25 @@ export async function startDaemon(opts = {}) {
|
|
|
60
60
|
emitter,
|
|
61
61
|
});
|
|
62
62
|
const sessionTracker = new SessionTracker(state, { adapters });
|
|
63
|
-
const metrics = new MetricsRegistry(
|
|
63
|
+
const metrics = new MetricsRegistry(lockManager, fuseEngine);
|
|
64
64
|
// Wire up events
|
|
65
|
-
emitter.on("fuse.
|
|
66
|
-
metrics.
|
|
65
|
+
emitter.on("fuse.expired", () => {
|
|
66
|
+
metrics.recordFuseExpired();
|
|
67
67
|
});
|
|
68
|
-
// 9.
|
|
69
|
-
|
|
68
|
+
// 9. Initial PID liveness cleanup for daemon-launched sessions
|
|
69
|
+
// (replaces the old validateAllSessions — much simpler, only checks launches)
|
|
70
|
+
const initialDead = sessionTracker.cleanupDeadLaunches();
|
|
71
|
+
if (initialDead.length > 0) {
|
|
72
|
+
for (const id of initialDead)
|
|
73
|
+
lockManager.autoUnlock(id);
|
|
74
|
+
console.error(`Startup cleanup: marked ${initialDead.length} dead launches as stopped`);
|
|
75
|
+
}
|
|
70
76
|
// 10. Resume fuse timers
|
|
71
77
|
fuseEngine.resumeTimers();
|
|
72
|
-
// 11. Start
|
|
73
|
-
sessionTracker.
|
|
78
|
+
// 11. Start periodic PID liveness check for lock cleanup (30s interval)
|
|
79
|
+
sessionTracker.startLaunchCleanup((deadId) => {
|
|
80
|
+
lockManager.autoUnlock(deadId);
|
|
81
|
+
});
|
|
74
82
|
// 12. Create request handler
|
|
75
83
|
const handleRequest = createRequestHandler({
|
|
76
84
|
sessionTracker,
|
|
@@ -140,7 +148,7 @@ export async function startDaemon(opts = {}) {
|
|
|
140
148
|
});
|
|
141
149
|
// Shutdown function
|
|
142
150
|
const shutdown = async () => {
|
|
143
|
-
sessionTracker.
|
|
151
|
+
sessionTracker.stopLaunchCleanup();
|
|
144
152
|
fuseEngine.shutdown();
|
|
145
153
|
state.flush();
|
|
146
154
|
await state.persist();
|
|
@@ -247,20 +255,104 @@ function createRequestHandler(ctx) {
|
|
|
247
255
|
const params = (req.params || {});
|
|
248
256
|
switch (req.method) {
|
|
249
257
|
case "session.list": {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
const adapterFilter = params.adapter;
|
|
259
|
+
const statusFilter = params.status;
|
|
260
|
+
const showAll = params.all;
|
|
261
|
+
const groupFilter = params.group;
|
|
262
|
+
// Fan out discover() to adapters (or just one if filtered)
|
|
263
|
+
const adapterEntries = adapterFilter
|
|
264
|
+
? Object.entries(ctx.adapters).filter(([name]) => name === adapterFilter)
|
|
265
|
+
: Object.entries(ctx.adapters);
|
|
266
|
+
const ADAPTER_TIMEOUT_MS = 5000;
|
|
267
|
+
const succeededAdapters = new Set();
|
|
268
|
+
const results = await Promise.allSettled(adapterEntries.map(([name, adapter]) => Promise.race([
|
|
269
|
+
adapter.discover().then((sessions) => {
|
|
270
|
+
succeededAdapters.add(name);
|
|
271
|
+
return sessions.map((s) => ({ ...s, adapter: name }));
|
|
272
|
+
}),
|
|
273
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Adapter ${name} timed out`)), ADAPTER_TIMEOUT_MS)),
|
|
274
|
+
])));
|
|
275
|
+
// Merge fulfilled results, skip failed adapters
|
|
276
|
+
const discovered = results
|
|
277
|
+
.filter((r) => r.status === "fulfilled")
|
|
278
|
+
.flatMap((r) => r.value);
|
|
279
|
+
// Reconcile with launch metadata and enrich
|
|
280
|
+
const { sessions: allSessions, stoppedLaunchIds } = ctx.sessionTracker.reconcileAndEnrich(discovered, succeededAdapters);
|
|
281
|
+
// Release locks for sessions that disappeared from adapter results
|
|
282
|
+
for (const id of stoppedLaunchIds) {
|
|
283
|
+
ctx.lockManager.autoUnlock(id);
|
|
284
|
+
}
|
|
285
|
+
// Apply filters
|
|
286
|
+
let sessions = allSessions;
|
|
287
|
+
if (statusFilter) {
|
|
288
|
+
sessions = sessions.filter((s) => s.status === statusFilter);
|
|
256
289
|
}
|
|
290
|
+
else if (!showAll) {
|
|
291
|
+
sessions = sessions.filter((s) => s.status === "running" || s.status === "idle");
|
|
292
|
+
}
|
|
293
|
+
if (groupFilter) {
|
|
294
|
+
sessions = sessions.filter((s) => s.group === groupFilter);
|
|
295
|
+
}
|
|
296
|
+
// Sort: running first, then by most recent
|
|
297
|
+
sessions.sort((a, b) => {
|
|
298
|
+
if (a.status === "running" && b.status !== "running")
|
|
299
|
+
return -1;
|
|
300
|
+
if (b.status === "running" && a.status !== "running")
|
|
301
|
+
return 1;
|
|
302
|
+
return (new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
303
|
+
});
|
|
304
|
+
// Update metrics gauge
|
|
305
|
+
ctx.metrics.setActiveSessionCount(allSessions.filter((s) => s.status === "running" || s.status === "idle").length);
|
|
257
306
|
return sessions;
|
|
258
307
|
}
|
|
259
308
|
case "session.status": {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
309
|
+
const id = params.id;
|
|
310
|
+
// Check launch metadata to determine adapter
|
|
311
|
+
const launchRecord = ctx.sessionTracker.getSession(id);
|
|
312
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
313
|
+
// Determine which adapters to search
|
|
314
|
+
const adaptersToSearch = adapterName
|
|
315
|
+
? Object.entries(ctx.adapters).filter(([name]) => name === adapterName)
|
|
316
|
+
: Object.entries(ctx.adapters);
|
|
317
|
+
// Search adapters for the session
|
|
318
|
+
for (const [name, adapter] of adaptersToSearch) {
|
|
319
|
+
try {
|
|
320
|
+
const discovered = await adapter.discover();
|
|
321
|
+
let match = discovered.find((d) => d.id === id);
|
|
322
|
+
// Prefix match
|
|
323
|
+
if (!match) {
|
|
324
|
+
const prefixMatches = discovered.filter((d) => d.id.startsWith(id));
|
|
325
|
+
if (prefixMatches.length === 1)
|
|
326
|
+
match = prefixMatches[0];
|
|
327
|
+
}
|
|
328
|
+
if (match) {
|
|
329
|
+
const meta = ctx.sessionTracker.getSession(match.id);
|
|
330
|
+
return {
|
|
331
|
+
id: match.id,
|
|
332
|
+
adapter: name,
|
|
333
|
+
status: match.status,
|
|
334
|
+
startedAt: match.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
335
|
+
stoppedAt: match.stoppedAt?.toISOString(),
|
|
336
|
+
cwd: match.cwd ?? meta?.cwd,
|
|
337
|
+
model: match.model ?? meta?.model,
|
|
338
|
+
prompt: match.prompt ?? meta?.prompt,
|
|
339
|
+
tokens: match.tokens,
|
|
340
|
+
cost: match.cost,
|
|
341
|
+
pid: match.pid,
|
|
342
|
+
spec: meta?.spec,
|
|
343
|
+
group: meta?.group,
|
|
344
|
+
meta: match.nativeMetadata ?? meta?.meta ?? {},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Adapter failed — try next
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Fall back to launch metadata if adapters didn't find it
|
|
353
|
+
if (launchRecord)
|
|
354
|
+
return launchRecord;
|
|
355
|
+
throw new Error(`Session not found: ${id}`);
|
|
264
356
|
}
|
|
265
357
|
case "session.peek": {
|
|
266
358
|
// Auto-detect adapter from tracked session, fall back to param or claude-code
|
|
@@ -315,48 +407,57 @@ function createRequestHandler(ctx) {
|
|
|
315
407
|
return record;
|
|
316
408
|
}
|
|
317
409
|
case "session.stop": {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
throw new Error(`Session not found: ${params.id}`);
|
|
410
|
+
const id = params.id;
|
|
411
|
+
const launchRecord = ctx.sessionTracker.getSession(id);
|
|
321
412
|
// Ghost pending entry with dead PID: remove from state with --force
|
|
322
|
-
if (
|
|
413
|
+
if (launchRecord?.id.startsWith("pending-") &&
|
|
323
414
|
params.force &&
|
|
324
|
-
|
|
325
|
-
!isProcessAlive(
|
|
326
|
-
ctx.lockManager.autoUnlock(
|
|
327
|
-
ctx.sessionTracker.removeSession(
|
|
415
|
+
launchRecord.pid &&
|
|
416
|
+
!isProcessAlive(launchRecord.pid)) {
|
|
417
|
+
ctx.lockManager.autoUnlock(launchRecord.id);
|
|
418
|
+
ctx.sessionTracker.removeSession(launchRecord.id);
|
|
328
419
|
return null;
|
|
329
420
|
}
|
|
330
|
-
const
|
|
421
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
422
|
+
if (!adapterName)
|
|
423
|
+
throw new Error(`Session not found: ${id}. Specify --adapter to stop a non-daemon session.`);
|
|
424
|
+
const adapter = ctx.adapters[adapterName];
|
|
331
425
|
if (!adapter)
|
|
332
|
-
throw new Error(`Unknown adapter: ${
|
|
333
|
-
|
|
426
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
427
|
+
const sessionId = launchRecord?.id || id;
|
|
428
|
+
await adapter.stop(sessionId, {
|
|
334
429
|
force: params.force,
|
|
335
430
|
});
|
|
336
431
|
// Remove auto-lock
|
|
337
|
-
ctx.lockManager.autoUnlock(
|
|
338
|
-
// Mark stopped
|
|
339
|
-
const stopped = ctx.sessionTracker.onSessionExit(
|
|
432
|
+
ctx.lockManager.autoUnlock(sessionId);
|
|
433
|
+
// Mark stopped in launch metadata
|
|
434
|
+
const stopped = ctx.sessionTracker.onSessionExit(sessionId);
|
|
340
435
|
if (stopped) {
|
|
341
|
-
ctx.fuseEngine.onSessionExit(stopped);
|
|
342
436
|
ctx.metrics.recordSessionStopped();
|
|
343
437
|
}
|
|
344
438
|
return null;
|
|
345
439
|
}
|
|
346
440
|
case "session.resume": {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
441
|
+
const id = params.id;
|
|
442
|
+
const launchRecord = ctx.sessionTracker.getSession(id);
|
|
443
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
444
|
+
if (!adapterName)
|
|
445
|
+
throw new Error(`Session not found: ${id}. Specify --adapter to resume a non-daemon session.`);
|
|
446
|
+
const adapter = ctx.adapters[adapterName];
|
|
351
447
|
if (!adapter)
|
|
352
|
-
throw new Error(`Unknown adapter: ${
|
|
353
|
-
await adapter.resume(
|
|
448
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
449
|
+
await adapter.resume(launchRecord?.id || id, params.message);
|
|
354
450
|
return null;
|
|
355
451
|
}
|
|
356
|
-
// --- Prune command (#40) ---
|
|
452
|
+
// --- Prune command (#40) --- kept for CLI backward compat
|
|
357
453
|
case "session.prune": {
|
|
358
|
-
|
|
359
|
-
|
|
454
|
+
// In the stateless model, there's no session registry to prune.
|
|
455
|
+
// Clean up dead launches (PID liveness check) as a best-effort action.
|
|
456
|
+
const deadIds = ctx.sessionTracker.cleanupDeadLaunches();
|
|
457
|
+
for (const id of deadIds) {
|
|
458
|
+
ctx.lockManager.autoUnlock(id);
|
|
459
|
+
}
|
|
460
|
+
return { pruned: deadIds.length };
|
|
360
461
|
}
|
|
361
462
|
case "lock.list":
|
|
362
463
|
return ctx.lockManager.listAll();
|
|
@@ -367,6 +468,21 @@ function createRequestHandler(ctx) {
|
|
|
367
468
|
return null;
|
|
368
469
|
case "fuse.list":
|
|
369
470
|
return ctx.fuseEngine.listActive();
|
|
471
|
+
case "fuse.set":
|
|
472
|
+
ctx.fuseEngine.setFuse({
|
|
473
|
+
directory: params.directory,
|
|
474
|
+
sessionId: params.sessionId,
|
|
475
|
+
ttlMs: params.ttlMs,
|
|
476
|
+
onExpire: params.onExpire,
|
|
477
|
+
label: params.label,
|
|
478
|
+
});
|
|
479
|
+
return null;
|
|
480
|
+
case "fuse.extend": {
|
|
481
|
+
const extended = ctx.fuseEngine.extendFuse(params.directory, params.ttlMs);
|
|
482
|
+
if (!extended)
|
|
483
|
+
throw new Error(`No active fuse for directory: ${params.directory}`);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
370
486
|
case "fuse.cancel":
|
|
371
487
|
ctx.fuseEngine.cancelFuse(params.directory);
|
|
372
488
|
return null;
|
|
@@ -374,7 +490,7 @@ function createRequestHandler(ctx) {
|
|
|
374
490
|
return {
|
|
375
491
|
pid: process.pid,
|
|
376
492
|
uptime: Date.now() - startTime,
|
|
377
|
-
sessions: ctx.
|
|
493
|
+
sessions: ctx.metrics.activeSessionCount,
|
|
378
494
|
locks: ctx.lockManager.listAll().length,
|
|
379
495
|
fuses: ctx.fuseEngine.listActive().length,
|
|
380
496
|
};
|
|
@@ -1,61 +1,60 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession } from "../core/types.js";
|
|
2
2
|
import type { SessionRecord, StateManager } from "./state.js";
|
|
3
3
|
export interface SessionTrackerOpts {
|
|
4
4
|
adapters: Record<string, AgentAdapter>;
|
|
5
|
-
pollIntervalMs?: number;
|
|
6
5
|
/** Override PID liveness check for testing (default: process.kill(pid, 0)) */
|
|
7
6
|
isProcessAlive?: (pid: number) => boolean;
|
|
8
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Simplified session tracker for the stateless daemon core (ADR 004).
|
|
10
|
+
*
|
|
11
|
+
* Adapters own session truth. The daemon only tracks:
|
|
12
|
+
* - Launch metadata (prompt, group, spec, cwd) for sessions launched via agentctl
|
|
13
|
+
* - Locks and fuses (handled by LockManager / FuseEngine)
|
|
14
|
+
*
|
|
15
|
+
* The old polling loop, pruning, and state-based session registry are removed.
|
|
16
|
+
* session.list now fans out adapter.discover() at call time.
|
|
17
|
+
*/
|
|
9
18
|
export declare class SessionTracker {
|
|
10
19
|
private state;
|
|
11
20
|
private adapters;
|
|
12
|
-
private pollIntervalMs;
|
|
13
|
-
private pollHandle;
|
|
14
|
-
private polling;
|
|
15
21
|
private readonly isProcessAlive;
|
|
22
|
+
private cleanupHandle;
|
|
16
23
|
constructor(state: StateManager, opts: SessionTrackerOpts);
|
|
17
|
-
startPolling(): void;
|
|
18
|
-
/** Run poll() with a guard to skip if the previous cycle is still running */
|
|
19
|
-
private guardedPoll;
|
|
20
|
-
stopPolling(): void;
|
|
21
|
-
private poll;
|
|
22
24
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* Start periodic PID liveness check for daemon-launched sessions.
|
|
26
|
+
* This is a lightweight check (no adapter fan-out) that runs every 30s
|
|
27
|
+
* to detect dead sessions and return their IDs for lock cleanup.
|
|
26
28
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|
|
41
|
-
/**
|
|
42
|
-
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
43
|
-
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
44
|
-
*/
|
|
45
|
-
private pruneOldSessions;
|
|
46
|
-
/** Track a newly launched session */
|
|
29
|
+
startLaunchCleanup(onDead?: (sessionId: string) => void): void;
|
|
30
|
+
stopLaunchCleanup(): void;
|
|
31
|
+
/** Track a newly launched session (stores launch metadata in state) */
|
|
47
32
|
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
48
|
-
/** Get session
|
|
33
|
+
/** Get session launch metadata by id (exact or prefix match) */
|
|
49
34
|
getSession(id: string): SessionRecord | undefined;
|
|
50
|
-
/**
|
|
51
|
-
listSessions(opts?: {
|
|
52
|
-
status?: string;
|
|
53
|
-
all?: boolean;
|
|
54
|
-
adapter?: string;
|
|
55
|
-
}): SessionRecord[];
|
|
56
|
-
activeCount(): number;
|
|
57
|
-
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
35
|
+
/** Remove a session from launch metadata */
|
|
58
36
|
removeSession(sessionId: string): void;
|
|
59
|
-
/** Called when a session stops —
|
|
37
|
+
/** Called when a session stops — marks it in launch metadata, returns the record */
|
|
60
38
|
onSessionExit(sessionId: string): SessionRecord | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Merge adapter-discovered sessions with daemon launch metadata.
|
|
41
|
+
*
|
|
42
|
+
* 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
|
|
43
|
+
* 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
|
|
44
|
+
* succeeded but didn't return them (and they're past the grace period).
|
|
45
|
+
* 3. Include recently-launched sessions that adapters haven't discovered yet.
|
|
46
|
+
*
|
|
47
|
+
* Returns the merged session list and IDs of sessions that were marked stopped
|
|
48
|
+
* (for lock cleanup by the caller).
|
|
49
|
+
*/
|
|
50
|
+
reconcileAndEnrich(discovered: DiscoveredSession[], succeededAdapters: Set<string>): {
|
|
51
|
+
sessions: SessionRecord[];
|
|
52
|
+
stoppedLaunchIds: string[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Check PID liveness for daemon-launched sessions.
|
|
56
|
+
* Returns IDs of sessions whose PIDs have died.
|
|
57
|
+
* This is a lightweight check (no adapter fan-out) for lock cleanup.
|
|
58
|
+
*/
|
|
59
|
+
cleanupDeadLaunches(): string[];
|
|
61
60
|
}
|