@orgloop/agentctl 1.3.0 → 1.5.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/README.md +10 -4
- package/dist/adapters/claude-code.js +1 -1
- package/dist/cli.js +18 -9
- package/dist/daemon/lock-manager.d.ts +2 -0
- package/dist/daemon/lock-manager.js +11 -1
- package/dist/daemon/metrics.d.ts +6 -3
- package/dist/daemon/metrics.js +11 -4
- package/dist/daemon/server.js +188 -44
- package/dist/daemon/session-tracker.d.ts +54 -37
- package/dist/daemon/session-tracker.js +225 -253
- package/dist/hooks.js +2 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,12 +42,16 @@ agentctl list
|
|
|
42
42
|
# List all sessions (including stopped, last 7 days)
|
|
43
43
|
agentctl list -a
|
|
44
44
|
|
|
45
|
-
# Peek at recent output from a session
|
|
45
|
+
# Peek at recent output from a session (alias: logs)
|
|
46
46
|
agentctl peek <session-id>
|
|
47
|
+
agentctl logs <session-id>
|
|
47
48
|
|
|
48
49
|
# Launch a new Claude Code session
|
|
49
50
|
agentctl launch -p "Read the spec and implement phase 2"
|
|
50
51
|
|
|
52
|
+
# Launch in a specific directory
|
|
53
|
+
agentctl launch -p "Fix the auth bug" --cwd ~/code/mono
|
|
54
|
+
|
|
51
55
|
# Launch a new Pi session
|
|
52
56
|
agentctl launch pi -p "Refactor the auth module"
|
|
53
57
|
|
|
@@ -58,7 +62,7 @@ agentctl stop <session-id>
|
|
|
58
62
|
agentctl resume <session-id> "fix the failing tests"
|
|
59
63
|
```
|
|
60
64
|
|
|
61
|
-
Session IDs support prefix matching — `agentctl peek abc123` matches any session starting with `abc123`.
|
|
65
|
+
Session IDs support prefix matching — `agentctl peek abc123` (or `agentctl logs abc123`) matches any session starting with `abc123`.
|
|
62
66
|
|
|
63
67
|
### Parallel Multi-Adapter Launch
|
|
64
68
|
|
|
@@ -141,20 +145,22 @@ agentctl status <id> [options]
|
|
|
141
145
|
--adapter <name> Adapter to use
|
|
142
146
|
--json Output as JSON
|
|
143
147
|
|
|
144
|
-
agentctl peek <id> [options]
|
|
148
|
+
agentctl peek|logs <id> [options]
|
|
145
149
|
-n, --lines <n> Number of recent messages (default: 20)
|
|
146
150
|
--adapter <name> Adapter to use
|
|
147
151
|
|
|
148
152
|
agentctl launch [adapter] [options]
|
|
149
153
|
-p, --prompt <text> Prompt to send (required)
|
|
150
154
|
--spec <path> Spec file path
|
|
151
|
-
--cwd <dir> Working directory
|
|
155
|
+
--cwd <dir> Working directory (default: current directory)
|
|
152
156
|
--model <model> Model to use (e.g. sonnet, opus)
|
|
153
157
|
--adapter <name> Adapter to launch (repeatable for parallel launch)
|
|
154
158
|
--matrix <file> YAML matrix file for advanced sweep launch
|
|
155
159
|
--group <id> Filter by launch group (for list command)
|
|
156
160
|
--force Override directory locks
|
|
157
161
|
|
|
162
|
+
When `--cwd` is omitted, the agent launches in the current working directory (`$PWD`). This means you should either `cd` into the target project first or pass `--cwd` explicitly. Launching from an unrelated directory (e.g. `~`) will start the agent in the wrong place.
|
|
163
|
+
|
|
158
164
|
agentctl stop <id> [options]
|
|
159
165
|
--force Force kill (SIGINT then SIGKILL)
|
|
160
166
|
--adapter <name> Adapter to use
|
|
@@ -206,7 +206,7 @@ export class ClaudeCodeAdapter {
|
|
|
206
206
|
// Claude Code's stream-json format emits a line with sessionId early on.
|
|
207
207
|
let resolvedSessionId;
|
|
208
208
|
if (pid) {
|
|
209
|
-
resolvedSessionId = await this.pollForSessionId(logPath, pid,
|
|
209
|
+
resolvedSessionId = await this.pollForSessionId(logPath, pid, 15000);
|
|
210
210
|
}
|
|
211
211
|
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
212
212
|
// Persist session metadata so status checks work after wrapper exits
|
package/dist/cli.js
CHANGED
|
@@ -336,13 +336,8 @@ program
|
|
|
336
336
|
console.error(`Session not found: ${id}`);
|
|
337
337
|
process.exit(1);
|
|
338
338
|
});
|
|
339
|
-
// peek
|
|
340
|
-
|
|
341
|
-
.command("peek <id>")
|
|
342
|
-
.description("Peek at recent output from a session")
|
|
343
|
-
.option("-n, --lines <n>", "Number of recent messages", "20")
|
|
344
|
-
.option("--adapter <name>", "Adapter to use")
|
|
345
|
-
.action(async (id, opts) => {
|
|
339
|
+
// Shared handler for peek/logs
|
|
340
|
+
async function peekAction(id, opts) {
|
|
346
341
|
const daemonRunning = await ensureDaemon();
|
|
347
342
|
if (daemonRunning) {
|
|
348
343
|
try {
|
|
@@ -386,7 +381,21 @@ program
|
|
|
386
381
|
}
|
|
387
382
|
console.error(`Session not found: ${id}`);
|
|
388
383
|
process.exit(1);
|
|
389
|
-
}
|
|
384
|
+
}
|
|
385
|
+
// peek
|
|
386
|
+
program
|
|
387
|
+
.command("peek <id>")
|
|
388
|
+
.description("Peek at recent output from a session (alias: logs)")
|
|
389
|
+
.option("-n, --lines <n>", "Number of recent messages", "20")
|
|
390
|
+
.option("--adapter <name>", "Adapter to use")
|
|
391
|
+
.action(peekAction);
|
|
392
|
+
// logs — alias for peek with higher default line count
|
|
393
|
+
program
|
|
394
|
+
.command("logs <id>")
|
|
395
|
+
.description("Show recent session output (alias for peek, default 50 lines)")
|
|
396
|
+
.option("-n, --lines <n>", "Number of recent messages", "50")
|
|
397
|
+
.option("--adapter <name>", "Adapter to use")
|
|
398
|
+
.action(peekAction);
|
|
390
399
|
// stop
|
|
391
400
|
program
|
|
392
401
|
.command("stop <id>")
|
|
@@ -453,7 +462,7 @@ program
|
|
|
453
462
|
.description("Launch a new agent session (or multiple with --adapter flags)")
|
|
454
463
|
.requiredOption("-p, --prompt <text>", "Prompt to send")
|
|
455
464
|
.option("--spec <path>", "Spec file path")
|
|
456
|
-
.option("--cwd <dir>", "Working directory")
|
|
465
|
+
.option("--cwd <dir>", "Working directory (default: current directory)")
|
|
457
466
|
.option("--model <model>", "Model to use (e.g. sonnet, opus)")
|
|
458
467
|
.option("--force", "Override directory locks")
|
|
459
468
|
.option("--worktree <repo>", "Auto-create git worktree from this repo before launch")
|
|
@@ -12,5 +12,7 @@ export declare class LockManager {
|
|
|
12
12
|
manualLock(directory: string, by?: string, reason?: string): Lock;
|
|
13
13
|
/** Manual unlock. Only removes manual locks. */
|
|
14
14
|
manualUnlock(directory: string): void;
|
|
15
|
+
/** Update the sessionId on auto-locks when a pending ID is resolved to a real UUID. */
|
|
16
|
+
updateAutoLockSessionId(oldId: string, newId: string): void;
|
|
15
17
|
listAll(): Lock[];
|
|
16
18
|
}
|
|
@@ -43,7 +43,7 @@ export class LockManager {
|
|
|
43
43
|
const resolved = path.resolve(directory);
|
|
44
44
|
const existing = this.check(resolved);
|
|
45
45
|
if (existing?.type === "manual") {
|
|
46
|
-
throw new Error(`Already manually locked by ${existing.lockedBy}
|
|
46
|
+
throw new Error(`Already manually locked by ${existing.lockedBy ?? "unknown"}${existing.reason ? `: ${existing.reason}` : ""}`);
|
|
47
47
|
}
|
|
48
48
|
const lock = {
|
|
49
49
|
directory: resolved,
|
|
@@ -65,6 +65,16 @@ export class LockManager {
|
|
|
65
65
|
throw new Error(`No manual lock on ${resolved}`);
|
|
66
66
|
this.state.removeLocks((l) => l.directory === resolved && l.type === "manual");
|
|
67
67
|
}
|
|
68
|
+
/** Update the sessionId on auto-locks when a pending ID is resolved to a real UUID. */
|
|
69
|
+
updateAutoLockSessionId(oldId, newId) {
|
|
70
|
+
const locks = this.state.getLocks();
|
|
71
|
+
for (const lock of locks) {
|
|
72
|
+
if (lock.type === "auto" && lock.sessionId === oldId) {
|
|
73
|
+
lock.sessionId = newId;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.state.markDirty();
|
|
77
|
+
}
|
|
68
78
|
listAll() {
|
|
69
79
|
return this.state.getLocks();
|
|
70
80
|
}
|
package/dist/daemon/metrics.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { FuseEngine } from "./fuse-engine.js";
|
|
2
2
|
import type { LockManager } from "./lock-manager.js";
|
|
3
|
-
import type { SessionTracker } from "./session-tracker.js";
|
|
4
3
|
export declare class MetricsRegistry {
|
|
5
|
-
private sessionTracker;
|
|
6
4
|
private lockManager;
|
|
7
5
|
private fuseEngine;
|
|
8
6
|
sessionsTotalCompleted: number;
|
|
@@ -10,7 +8,12 @@ export declare class MetricsRegistry {
|
|
|
10
8
|
sessionsTotalStopped: number;
|
|
11
9
|
fusesExpiredTotal: number;
|
|
12
10
|
sessionDurations: number[];
|
|
13
|
-
|
|
11
|
+
/** Last-known active session count, updated by session.list fan-out */
|
|
12
|
+
private _activeSessionCount;
|
|
13
|
+
constructor(lockManager: LockManager, fuseEngine: FuseEngine);
|
|
14
|
+
/** Update the active session gauge (called after session.list fan-out) */
|
|
15
|
+
setActiveSessionCount(count: number): void;
|
|
16
|
+
get activeSessionCount(): number;
|
|
14
17
|
recordSessionCompleted(durationSeconds?: number): void;
|
|
15
18
|
recordSessionFailed(durationSeconds?: number): void;
|
|
16
19
|
recordSessionStopped(durationSeconds?: number): void;
|
package/dist/daemon/metrics.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export class MetricsRegistry {
|
|
2
|
-
sessionTracker;
|
|
3
2
|
lockManager;
|
|
4
3
|
fuseEngine;
|
|
5
4
|
sessionsTotalCompleted = 0;
|
|
@@ -7,11 +6,19 @@ export class MetricsRegistry {
|
|
|
7
6
|
sessionsTotalStopped = 0;
|
|
8
7
|
fusesExpiredTotal = 0;
|
|
9
8
|
sessionDurations = []; // seconds
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** Last-known active session count, updated by session.list fan-out */
|
|
10
|
+
_activeSessionCount = 0;
|
|
11
|
+
constructor(lockManager, fuseEngine) {
|
|
12
12
|
this.lockManager = lockManager;
|
|
13
13
|
this.fuseEngine = fuseEngine;
|
|
14
14
|
}
|
|
15
|
+
/** Update the active session gauge (called after session.list fan-out) */
|
|
16
|
+
setActiveSessionCount(count) {
|
|
17
|
+
this._activeSessionCount = count;
|
|
18
|
+
}
|
|
19
|
+
get activeSessionCount() {
|
|
20
|
+
return this._activeSessionCount;
|
|
21
|
+
}
|
|
15
22
|
recordSessionCompleted(durationSeconds) {
|
|
16
23
|
this.sessionsTotalCompleted++;
|
|
17
24
|
if (durationSeconds != null)
|
|
@@ -43,7 +50,7 @@ export class MetricsRegistry {
|
|
|
43
50
|
lines.push(labels ? `${name}{${labels}} ${value}` : `${name} ${value}`);
|
|
44
51
|
};
|
|
45
52
|
// Gauges
|
|
46
|
-
g("agentctl_sessions_active", "Number of active sessions", this.
|
|
53
|
+
g("agentctl_sessions_active", "Number of active sessions", this._activeSessionCount);
|
|
47
54
|
const locks = this.lockManager.listAll();
|
|
48
55
|
g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "auto").length, 'type="auto"');
|
|
49
56
|
g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "manual").length, 'type="manual"');
|
package/dist/daemon/server.js
CHANGED
|
@@ -60,17 +60,29 @@ 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
65
|
emitter.on("fuse.expired", () => {
|
|
66
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
|
+
});
|
|
82
|
+
// 11b. Start periodic background resolution of pending-* session IDs (10s interval)
|
|
83
|
+
sessionTracker.startPendingResolution((oldId, newId) => {
|
|
84
|
+
lockManager.updateAutoLockSessionId(oldId, newId);
|
|
85
|
+
});
|
|
74
86
|
// 12. Create request handler
|
|
75
87
|
const handleRequest = createRequestHandler({
|
|
76
88
|
sessionTracker,
|
|
@@ -140,7 +152,8 @@ export async function startDaemon(opts = {}) {
|
|
|
140
152
|
});
|
|
141
153
|
// Shutdown function
|
|
142
154
|
const shutdown = async () => {
|
|
143
|
-
sessionTracker.
|
|
155
|
+
sessionTracker.stopLaunchCleanup();
|
|
156
|
+
sessionTracker.stopPendingResolution();
|
|
144
157
|
fuseEngine.shutdown();
|
|
145
158
|
state.flush();
|
|
146
159
|
await state.persist();
|
|
@@ -247,30 +260,132 @@ function createRequestHandler(ctx) {
|
|
|
247
260
|
const params = (req.params || {});
|
|
248
261
|
switch (req.method) {
|
|
249
262
|
case "session.list": {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
const adapterFilter = params.adapter;
|
|
264
|
+
const statusFilter = params.status;
|
|
265
|
+
const showAll = params.all;
|
|
266
|
+
const groupFilter = params.group;
|
|
267
|
+
// Fan out discover() to adapters (or just one if filtered)
|
|
268
|
+
const adapterEntries = adapterFilter
|
|
269
|
+
? Object.entries(ctx.adapters).filter(([name]) => name === adapterFilter)
|
|
270
|
+
: Object.entries(ctx.adapters);
|
|
271
|
+
const ADAPTER_TIMEOUT_MS = 5000;
|
|
272
|
+
const succeededAdapters = new Set();
|
|
273
|
+
const results = await Promise.allSettled(adapterEntries.map(([name, adapter]) => Promise.race([
|
|
274
|
+
adapter.discover().then((sessions) => {
|
|
275
|
+
succeededAdapters.add(name);
|
|
276
|
+
return sessions.map((s) => ({ ...s, adapter: name }));
|
|
277
|
+
}),
|
|
278
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Adapter ${name} timed out`)), ADAPTER_TIMEOUT_MS)),
|
|
279
|
+
])));
|
|
280
|
+
// Merge fulfilled results, skip failed adapters
|
|
281
|
+
const discovered = results
|
|
282
|
+
.filter((r) => r.status === "fulfilled")
|
|
283
|
+
.flatMap((r) => r.value);
|
|
284
|
+
// Reconcile with launch metadata and enrich
|
|
285
|
+
const { sessions: allSessions, stoppedLaunchIds } = ctx.sessionTracker.reconcileAndEnrich(discovered, succeededAdapters);
|
|
286
|
+
// Release locks for sessions that disappeared from adapter results
|
|
287
|
+
for (const id of stoppedLaunchIds) {
|
|
288
|
+
ctx.lockManager.autoUnlock(id);
|
|
289
|
+
}
|
|
290
|
+
// Apply filters
|
|
291
|
+
let sessions = allSessions;
|
|
292
|
+
if (statusFilter) {
|
|
293
|
+
sessions = sessions.filter((s) => s.status === statusFilter);
|
|
294
|
+
}
|
|
295
|
+
else if (!showAll) {
|
|
296
|
+
sessions = sessions.filter((s) => s.status === "running" || s.status === "idle");
|
|
256
297
|
}
|
|
298
|
+
if (groupFilter) {
|
|
299
|
+
sessions = sessions.filter((s) => s.group === groupFilter);
|
|
300
|
+
}
|
|
301
|
+
// Sort: running first, then by most recent
|
|
302
|
+
sessions.sort((a, b) => {
|
|
303
|
+
if (a.status === "running" && b.status !== "running")
|
|
304
|
+
return -1;
|
|
305
|
+
if (b.status === "running" && a.status !== "running")
|
|
306
|
+
return 1;
|
|
307
|
+
return (new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
308
|
+
});
|
|
309
|
+
// Update metrics gauge
|
|
310
|
+
ctx.metrics.setActiveSessionCount(allSessions.filter((s) => s.status === "running" || s.status === "idle").length);
|
|
257
311
|
return sessions;
|
|
258
312
|
}
|
|
259
313
|
case "session.status": {
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
314
|
+
let id = params.id;
|
|
315
|
+
// On-demand resolution: if pending-*, try to resolve first
|
|
316
|
+
const trackedForResolve = ctx.sessionTracker.getSession(id);
|
|
317
|
+
const resolveTarget = trackedForResolve?.id || id;
|
|
318
|
+
if (resolveTarget.startsWith("pending-")) {
|
|
319
|
+
const resolvedId = await ctx.sessionTracker.resolvePendingId(resolveTarget);
|
|
320
|
+
if (resolvedId !== resolveTarget) {
|
|
321
|
+
ctx.lockManager.updateAutoLockSessionId(resolveTarget, resolvedId);
|
|
322
|
+
id = resolvedId;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Check launch metadata to determine adapter
|
|
326
|
+
const launchRecord = ctx.sessionTracker.getSession(id);
|
|
327
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
328
|
+
// Determine which adapters to search
|
|
329
|
+
const adaptersToSearch = adapterName
|
|
330
|
+
? Object.entries(ctx.adapters).filter(([name]) => name === adapterName)
|
|
331
|
+
: Object.entries(ctx.adapters);
|
|
332
|
+
// Search adapters for the session
|
|
333
|
+
for (const [name, adapter] of adaptersToSearch) {
|
|
334
|
+
try {
|
|
335
|
+
const discovered = await adapter.discover();
|
|
336
|
+
let match = discovered.find((d) => d.id === id);
|
|
337
|
+
// Prefix match
|
|
338
|
+
if (!match) {
|
|
339
|
+
const prefixMatches = discovered.filter((d) => d.id.startsWith(id));
|
|
340
|
+
if (prefixMatches.length === 1)
|
|
341
|
+
match = prefixMatches[0];
|
|
342
|
+
}
|
|
343
|
+
if (match) {
|
|
344
|
+
const meta = ctx.sessionTracker.getSession(match.id);
|
|
345
|
+
return {
|
|
346
|
+
id: match.id,
|
|
347
|
+
adapter: name,
|
|
348
|
+
status: match.status,
|
|
349
|
+
startedAt: match.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
350
|
+
stoppedAt: match.stoppedAt?.toISOString(),
|
|
351
|
+
cwd: match.cwd ?? meta?.cwd,
|
|
352
|
+
model: match.model ?? meta?.model,
|
|
353
|
+
prompt: match.prompt ?? meta?.prompt,
|
|
354
|
+
tokens: match.tokens,
|
|
355
|
+
cost: match.cost,
|
|
356
|
+
pid: match.pid,
|
|
357
|
+
spec: meta?.spec,
|
|
358
|
+
group: meta?.group,
|
|
359
|
+
meta: match.nativeMetadata ?? meta?.meta ?? {},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Adapter failed — try next
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Fall back to launch metadata if adapters didn't find it
|
|
368
|
+
if (launchRecord)
|
|
369
|
+
return launchRecord;
|
|
370
|
+
throw new Error(`Session not found: ${id}`);
|
|
264
371
|
}
|
|
265
372
|
case "session.peek": {
|
|
266
373
|
// Auto-detect adapter from tracked session, fall back to param or claude-code
|
|
267
|
-
|
|
374
|
+
let tracked = ctx.sessionTracker.getSession(params.id);
|
|
375
|
+
let peekId = tracked?.id || params.id;
|
|
376
|
+
// On-demand resolution: if pending-*, try to resolve before peeking
|
|
377
|
+
if (peekId.startsWith("pending-")) {
|
|
378
|
+
const resolvedId = await ctx.sessionTracker.resolvePendingId(peekId);
|
|
379
|
+
if (resolvedId !== peekId) {
|
|
380
|
+
ctx.lockManager.updateAutoLockSessionId(peekId, resolvedId);
|
|
381
|
+
peekId = resolvedId;
|
|
382
|
+
tracked = ctx.sessionTracker.getSession(resolvedId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
268
385
|
const adapterName = params.adapter || tracked?.adapter || "claude-code";
|
|
269
386
|
const adapter = ctx.adapters[adapterName];
|
|
270
387
|
if (!adapter)
|
|
271
388
|
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
272
|
-
// Use the full session ID if we resolved it from the tracker
|
|
273
|
-
const peekId = tracked?.id || params.id;
|
|
274
389
|
return adapter.peek(peekId, {
|
|
275
390
|
lines: params.lines,
|
|
276
391
|
});
|
|
@@ -281,7 +396,7 @@ function createRequestHandler(ctx) {
|
|
|
281
396
|
const lock = ctx.lockManager.check(cwd);
|
|
282
397
|
if (lock && !params.force) {
|
|
283
398
|
if (lock.type === "manual") {
|
|
284
|
-
throw new Error(`Directory locked by ${lock.lockedBy}
|
|
399
|
+
throw new Error(`Directory locked by ${lock.lockedBy ?? "unknown"}${lock.reason ? `: ${lock.reason}` : ""}. Use --force to override.`);
|
|
285
400
|
}
|
|
286
401
|
throw new Error(`Directory in use by session ${lock.sessionId?.slice(0, 8)}. Use --force to override.`);
|
|
287
402
|
}
|
|
@@ -315,47 +430,76 @@ function createRequestHandler(ctx) {
|
|
|
315
430
|
return record;
|
|
316
431
|
}
|
|
317
432
|
case "session.stop": {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
433
|
+
const id = params.id;
|
|
434
|
+
let launchRecord = ctx.sessionTracker.getSession(id);
|
|
435
|
+
let sessionId = launchRecord?.id || id;
|
|
436
|
+
// On-demand resolution: if pending-*, try to resolve before stopping
|
|
437
|
+
if (sessionId.startsWith("pending-")) {
|
|
438
|
+
const resolvedId = await ctx.sessionTracker.resolvePendingId(sessionId);
|
|
439
|
+
if (resolvedId !== sessionId) {
|
|
440
|
+
ctx.lockManager.updateAutoLockSessionId(sessionId, resolvedId);
|
|
441
|
+
sessionId = resolvedId;
|
|
442
|
+
launchRecord = ctx.sessionTracker.getSession(resolvedId);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
321
445
|
// Ghost pending entry with dead PID: remove from state with --force
|
|
322
|
-
if (
|
|
446
|
+
if (sessionId.startsWith("pending-") &&
|
|
323
447
|
params.force &&
|
|
324
|
-
|
|
325
|
-
!isProcessAlive(
|
|
326
|
-
ctx.lockManager.autoUnlock(
|
|
327
|
-
ctx.sessionTracker.removeSession(
|
|
448
|
+
launchRecord?.pid &&
|
|
449
|
+
!isProcessAlive(launchRecord.pid)) {
|
|
450
|
+
ctx.lockManager.autoUnlock(sessionId);
|
|
451
|
+
ctx.sessionTracker.removeSession(sessionId);
|
|
328
452
|
return null;
|
|
329
453
|
}
|
|
330
|
-
const
|
|
454
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
455
|
+
if (!adapterName)
|
|
456
|
+
throw new Error(`Session not found: ${id}. Specify --adapter to stop a non-daemon session.`);
|
|
457
|
+
const adapter = ctx.adapters[adapterName];
|
|
331
458
|
if (!adapter)
|
|
332
|
-
throw new Error(`Unknown adapter: ${
|
|
333
|
-
await adapter.stop(
|
|
459
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
460
|
+
await adapter.stop(sessionId, {
|
|
334
461
|
force: params.force,
|
|
335
462
|
});
|
|
336
463
|
// Remove auto-lock
|
|
337
|
-
ctx.lockManager.autoUnlock(
|
|
338
|
-
// Mark stopped
|
|
339
|
-
const stopped = ctx.sessionTracker.onSessionExit(
|
|
464
|
+
ctx.lockManager.autoUnlock(sessionId);
|
|
465
|
+
// Mark stopped in launch metadata
|
|
466
|
+
const stopped = ctx.sessionTracker.onSessionExit(sessionId);
|
|
340
467
|
if (stopped) {
|
|
341
468
|
ctx.metrics.recordSessionStopped();
|
|
342
469
|
}
|
|
343
470
|
return null;
|
|
344
471
|
}
|
|
345
472
|
case "session.resume": {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
473
|
+
const id = params.id;
|
|
474
|
+
let launchRecord = ctx.sessionTracker.getSession(id);
|
|
475
|
+
let resumeId = launchRecord?.id || id;
|
|
476
|
+
// On-demand resolution: if pending-*, try to resolve before resuming
|
|
477
|
+
if (resumeId.startsWith("pending-")) {
|
|
478
|
+
const resolvedId = await ctx.sessionTracker.resolvePendingId(resumeId);
|
|
479
|
+
if (resolvedId !== resumeId) {
|
|
480
|
+
ctx.lockManager.updateAutoLockSessionId(resumeId, resolvedId);
|
|
481
|
+
resumeId = resolvedId;
|
|
482
|
+
launchRecord = ctx.sessionTracker.getSession(resolvedId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const adapterName = params.adapter || launchRecord?.adapter;
|
|
486
|
+
if (!adapterName)
|
|
487
|
+
throw new Error(`Session not found: ${id}. Specify --adapter to resume a non-daemon session.`);
|
|
488
|
+
const adapter = ctx.adapters[adapterName];
|
|
350
489
|
if (!adapter)
|
|
351
|
-
throw new Error(`Unknown adapter: ${
|
|
352
|
-
await adapter.resume(
|
|
490
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
491
|
+
await adapter.resume(resumeId, params.message);
|
|
353
492
|
return null;
|
|
354
493
|
}
|
|
355
|
-
// --- Prune command (#40) ---
|
|
494
|
+
// --- Prune command (#40) --- kept for CLI backward compat
|
|
356
495
|
case "session.prune": {
|
|
357
|
-
|
|
358
|
-
|
|
496
|
+
// In the stateless model, there's no session registry to prune.
|
|
497
|
+
// Clean up dead launches (PID liveness check) as a best-effort action.
|
|
498
|
+
const deadIds = ctx.sessionTracker.cleanupDeadLaunches();
|
|
499
|
+
for (const id of deadIds) {
|
|
500
|
+
ctx.lockManager.autoUnlock(id);
|
|
501
|
+
}
|
|
502
|
+
return { pruned: deadIds.length };
|
|
359
503
|
}
|
|
360
504
|
case "lock.list":
|
|
361
505
|
return ctx.lockManager.listAll();
|
|
@@ -388,7 +532,7 @@ function createRequestHandler(ctx) {
|
|
|
388
532
|
return {
|
|
389
533
|
pid: process.pid,
|
|
390
534
|
uptime: Date.now() - startTime,
|
|
391
|
-
sessions: ctx.
|
|
535
|
+
sessions: ctx.metrics.activeSessionCount,
|
|
392
536
|
locks: ctx.lockManager.listAll().length,
|
|
393
537
|
fuses: ctx.fuseEngine.listActive().length,
|
|
394
538
|
};
|
|
@@ -1,61 +1,78 @@
|
|
|
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;
|
|
23
|
+
private pendingResolutionHandle;
|
|
16
24
|
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
25
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* Start periodic PID liveness check for daemon-launched sessions.
|
|
27
|
+
* This is a lightweight check (no adapter fan-out) that runs every 30s
|
|
28
|
+
* to detect dead sessions and return their IDs for lock cleanup.
|
|
26
29
|
*/
|
|
27
|
-
|
|
30
|
+
startLaunchCleanup(onDead?: (sessionId: string) => void): void;
|
|
31
|
+
stopLaunchCleanup(): void;
|
|
28
32
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* immediately marked as "stopped". This prevents unbounded growth of
|
|
32
|
-
* ghost sessions across daemon restarts.
|
|
33
|
+
* Start periodic background resolution of pending-* session IDs.
|
|
34
|
+
* Runs every 10s, discovers real UUIDs via adapter PID matching.
|
|
33
35
|
*/
|
|
34
|
-
|
|
36
|
+
startPendingResolution(onResolved?: (oldId: string, newId: string) => void): void;
|
|
37
|
+
stopPendingResolution(): void;
|
|
35
38
|
/**
|
|
36
|
-
*
|
|
37
|
-
* Returns the
|
|
38
|
-
* Called via `agentctl prune` command.
|
|
39
|
+
* Resolve a single pending-* session ID on demand.
|
|
40
|
+
* Returns the resolved real UUID, or the original ID if resolution fails.
|
|
39
41
|
*/
|
|
40
|
-
|
|
42
|
+
resolvePendingId(id: string): Promise<string>;
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
* Batch-resolve all pending-* session IDs via adapter discovery.
|
|
45
|
+
* Groups pending sessions by adapter to minimize discover() calls.
|
|
46
|
+
* Returns a map of oldId → newId for each resolved session.
|
|
44
47
|
*/
|
|
45
|
-
|
|
46
|
-
/** Track a newly launched session */
|
|
48
|
+
resolvePendingSessions(): Promise<Map<string, string>>;
|
|
49
|
+
/** Track a newly launched session (stores launch metadata in state) */
|
|
47
50
|
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
48
|
-
/** Get session
|
|
51
|
+
/** Get session launch metadata by id (exact or prefix match) */
|
|
49
52
|
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) */
|
|
53
|
+
/** Remove a session from launch metadata */
|
|
58
54
|
removeSession(sessionId: string): void;
|
|
59
|
-
/** Called when a session stops —
|
|
55
|
+
/** Called when a session stops — marks it in launch metadata, returns the record */
|
|
60
56
|
onSessionExit(sessionId: string): SessionRecord | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Merge adapter-discovered sessions with daemon launch metadata.
|
|
59
|
+
*
|
|
60
|
+
* 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
|
|
61
|
+
* 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
|
|
62
|
+
* succeeded but didn't return them (and they're past the grace period).
|
|
63
|
+
* 3. Include recently-launched sessions that adapters haven't discovered yet.
|
|
64
|
+
*
|
|
65
|
+
* Returns the merged session list and IDs of sessions that were marked stopped
|
|
66
|
+
* (for lock cleanup by the caller).
|
|
67
|
+
*/
|
|
68
|
+
reconcileAndEnrich(discovered: DiscoveredSession[], succeededAdapters: Set<string>): {
|
|
69
|
+
sessions: SessionRecord[];
|
|
70
|
+
stoppedLaunchIds: string[];
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Check PID liveness for daemon-launched sessions.
|
|
74
|
+
* Returns IDs of sessions whose PIDs have died.
|
|
75
|
+
* This is a lightweight check (no adapter fan-out) for lock cleanup.
|
|
76
|
+
*/
|
|
77
|
+
cleanupDeadLaunches(): string[];
|
|
61
78
|
}
|
|
@@ -1,220 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Grace period for recently-launched sessions.
|
|
3
|
+
* If a session was launched less than this many ms ago and the adapter
|
|
4
|
+
* doesn't return it yet, don't mark it stopped — the adapter may not
|
|
5
|
+
* have discovered it yet.
|
|
6
|
+
*/
|
|
7
|
+
const LAUNCH_GRACE_PERIOD_MS = 30_000;
|
|
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
|
+
*/
|
|
3
18
|
export class SessionTracker {
|
|
4
19
|
state;
|
|
5
20
|
adapters;
|
|
6
|
-
pollIntervalMs;
|
|
7
|
-
pollHandle = null;
|
|
8
|
-
polling = false;
|
|
9
21
|
isProcessAlive;
|
|
22
|
+
cleanupHandle = null;
|
|
23
|
+
pendingResolutionHandle = null;
|
|
10
24
|
constructor(state, opts) {
|
|
11
25
|
this.state = state;
|
|
12
26
|
this.adapters = opts.adapters;
|
|
13
|
-
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
14
27
|
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
15
28
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
23
|
-
this.pollHandle = setInterval(() => {
|
|
24
|
-
this.guardedPoll();
|
|
25
|
-
}, this.pollIntervalMs);
|
|
26
|
-
}
|
|
27
|
-
/** Run poll() with a guard to skip if the previous cycle is still running */
|
|
28
|
-
guardedPoll() {
|
|
29
|
-
if (this.polling)
|
|
29
|
+
/**
|
|
30
|
+
* Start periodic PID liveness check for daemon-launched sessions.
|
|
31
|
+
* This is a lightweight check (no adapter fan-out) that runs every 30s
|
|
32
|
+
* to detect dead sessions and return their IDs for lock cleanup.
|
|
33
|
+
*/
|
|
34
|
+
startLaunchCleanup(onDead) {
|
|
35
|
+
if (this.cleanupHandle)
|
|
30
36
|
return;
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
stopPolling() {
|
|
39
|
-
if (this.pollHandle) {
|
|
40
|
-
clearInterval(this.pollHandle);
|
|
41
|
-
this.pollHandle = null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
async poll() {
|
|
45
|
-
// Collect PIDs from all adapter-discovered sessions (the source of truth)
|
|
46
|
-
const adapterPidToId = new Map();
|
|
47
|
-
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
48
|
-
try {
|
|
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);
|
|
54
|
-
}
|
|
55
|
-
const existing = this.state.getSession(disc.id);
|
|
56
|
-
const record = discoveredToRecord(disc, adapterName);
|
|
57
|
-
if (!existing) {
|
|
58
|
-
this.state.setSession(disc.id, record);
|
|
59
|
-
}
|
|
60
|
-
else if (existing.status !== record.status ||
|
|
61
|
-
(!existing.model && record.model)) {
|
|
62
|
-
// Status changed or model resolved — update, preserving metadata
|
|
63
|
-
this.state.setSession(disc.id, {
|
|
64
|
-
...existing,
|
|
65
|
-
status: record.status,
|
|
66
|
-
stoppedAt: record.stoppedAt,
|
|
67
|
-
model: record.model || existing.model,
|
|
68
|
-
tokens: record.tokens,
|
|
69
|
-
cost: record.cost,
|
|
70
|
-
prompt: record.prompt || existing.prompt,
|
|
71
|
-
pid: record.pid,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
// Adapter unavailable — skip
|
|
37
|
+
this.cleanupHandle = setInterval(() => {
|
|
38
|
+
const dead = this.cleanupDeadLaunches();
|
|
39
|
+
if (onDead) {
|
|
40
|
+
for (const id of dead)
|
|
41
|
+
onDead(id);
|
|
78
42
|
}
|
|
43
|
+
}, 30_000);
|
|
44
|
+
}
|
|
45
|
+
stopLaunchCleanup() {
|
|
46
|
+
if (this.cleanupHandle) {
|
|
47
|
+
clearInterval(this.cleanupHandle);
|
|
48
|
+
this.cleanupHandle = null;
|
|
79
49
|
}
|
|
80
|
-
// Reap stale entries from daemon state
|
|
81
|
-
this.reapStaleEntries(adapterPidToId);
|
|
82
50
|
}
|
|
83
51
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
52
|
+
* Start periodic background resolution of pending-* session IDs.
|
|
53
|
+
* Runs every 10s, discovers real UUIDs via adapter PID matching.
|
|
87
54
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
this.state.removeSession(id);
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
|
|
101
|
-
if ((record.status === "running" || record.status === "idle") &&
|
|
102
|
-
record.pid) {
|
|
103
|
-
// Only reap if the adapter didn't return this session as running
|
|
104
|
-
// (adapter is the source of truth for sessions it knows about)
|
|
105
|
-
const adapterId = adapterPidToId.get(record.pid);
|
|
106
|
-
if (adapterId === id)
|
|
107
|
-
continue; // Adapter confirmed this PID is active
|
|
108
|
-
if (!this.isProcessAlive(record.pid)) {
|
|
109
|
-
this.state.setSession(id, {
|
|
110
|
-
...record,
|
|
111
|
-
status: "stopped",
|
|
112
|
-
stoppedAt: new Date().toISOString(),
|
|
113
|
-
});
|
|
55
|
+
startPendingResolution(onResolved) {
|
|
56
|
+
if (this.pendingResolutionHandle)
|
|
57
|
+
return;
|
|
58
|
+
this.pendingResolutionHandle = setInterval(async () => {
|
|
59
|
+
const resolved = await this.resolvePendingSessions();
|
|
60
|
+
if (onResolved) {
|
|
61
|
+
for (const [oldId, newId] of resolved) {
|
|
62
|
+
onResolved(oldId, newId);
|
|
114
63
|
}
|
|
115
64
|
}
|
|
65
|
+
}, 10_000);
|
|
66
|
+
}
|
|
67
|
+
stopPendingResolution() {
|
|
68
|
+
if (this.pendingResolutionHandle) {
|
|
69
|
+
clearInterval(this.pendingResolutionHandle);
|
|
70
|
+
this.pendingResolutionHandle = null;
|
|
116
71
|
}
|
|
117
72
|
}
|
|
118
73
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* immediately marked as "stopped". This prevents unbounded growth of
|
|
122
|
-
* ghost sessions across daemon restarts.
|
|
74
|
+
* Resolve a single pending-* session ID on demand.
|
|
75
|
+
* Returns the resolved real UUID, or the original ID if resolution fails.
|
|
123
76
|
*/
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
this.state.setSession(id, {
|
|
143
|
-
...record,
|
|
144
|
-
status: "stopped",
|
|
145
|
-
stoppedAt: new Date().toISOString(),
|
|
146
|
-
});
|
|
147
|
-
cleaned++;
|
|
77
|
+
async resolvePendingId(id) {
|
|
78
|
+
if (!id.startsWith("pending-"))
|
|
79
|
+
return id;
|
|
80
|
+
const record = this.getSession(id);
|
|
81
|
+
if (!record || !record.pid)
|
|
82
|
+
return id;
|
|
83
|
+
const adapter = this.adapters[record.adapter];
|
|
84
|
+
if (!adapter)
|
|
85
|
+
return id;
|
|
86
|
+
try {
|
|
87
|
+
const discovered = await adapter.discover();
|
|
88
|
+
const match = discovered.find((d) => d.pid === record.pid);
|
|
89
|
+
if (match && match.id !== id) {
|
|
90
|
+
// Resolve: move state from pending ID to real UUID
|
|
91
|
+
const updatedRecord = { ...record, id: match.id };
|
|
92
|
+
this.state.removeSession(id);
|
|
93
|
+
this.state.setSession(match.id, updatedRecord);
|
|
94
|
+
return match.id;
|
|
148
95
|
}
|
|
149
96
|
}
|
|
150
|
-
|
|
151
|
-
|
|
97
|
+
catch {
|
|
98
|
+
// Adapter failed — return original ID
|
|
152
99
|
}
|
|
100
|
+
return id;
|
|
153
101
|
}
|
|
154
102
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
103
|
+
* Batch-resolve all pending-* session IDs via adapter discovery.
|
|
104
|
+
* Groups pending sessions by adapter to minimize discover() calls.
|
|
105
|
+
* Returns a map of oldId → newId for each resolved session.
|
|
158
106
|
*/
|
|
159
|
-
|
|
107
|
+
async resolvePendingSessions() {
|
|
108
|
+
const resolved = new Map();
|
|
160
109
|
const sessions = this.state.getSessions();
|
|
161
|
-
|
|
110
|
+
// Group pending sessions by adapter
|
|
111
|
+
const pendingByAdapter = new Map();
|
|
162
112
|
for (const [id, record] of Object.entries(sessions)) {
|
|
163
|
-
|
|
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
|
-
}
|
|
113
|
+
if (!id.startsWith("pending-"))
|
|
175
114
|
continue;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (record.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
else if (!record.pid) {
|
|
184
|
-
this.state.removeSession(id);
|
|
185
|
-
pruned++;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
115
|
+
if (record.status === "stopped" || record.status === "completed")
|
|
116
|
+
continue;
|
|
117
|
+
if (!record.pid)
|
|
118
|
+
continue;
|
|
119
|
+
const list = pendingByAdapter.get(record.adapter) || [];
|
|
120
|
+
list.push({ id, record });
|
|
121
|
+
pendingByAdapter.set(record.adapter, list);
|
|
188
122
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
pruneOldSessions() {
|
|
196
|
-
const sessions = this.state.getSessions();
|
|
197
|
-
const now = Date.now();
|
|
198
|
-
let pruned = 0;
|
|
199
|
-
for (const [id, record] of Object.entries(sessions)) {
|
|
200
|
-
if (record.status !== "stopped" &&
|
|
201
|
-
record.status !== "completed" &&
|
|
202
|
-
record.status !== "failed") {
|
|
123
|
+
if (pendingByAdapter.size === 0)
|
|
124
|
+
return resolved;
|
|
125
|
+
// For each adapter with pending sessions, run discover() once
|
|
126
|
+
for (const [adapterName, pendings] of pendingByAdapter) {
|
|
127
|
+
const adapter = this.adapters[adapterName];
|
|
128
|
+
if (!adapter)
|
|
203
129
|
continue;
|
|
130
|
+
try {
|
|
131
|
+
const discovered = await adapter.discover();
|
|
132
|
+
const pidToId = new Map();
|
|
133
|
+
for (const d of discovered) {
|
|
134
|
+
if (d.pid)
|
|
135
|
+
pidToId.set(d.pid, d.id);
|
|
136
|
+
}
|
|
137
|
+
for (const { id, record } of pendings) {
|
|
138
|
+
if (!record.pid)
|
|
139
|
+
continue;
|
|
140
|
+
const resolvedId = pidToId.get(record.pid);
|
|
141
|
+
if (resolvedId && resolvedId !== id) {
|
|
142
|
+
const updatedRecord = { ...record, id: resolvedId };
|
|
143
|
+
this.state.removeSession(id);
|
|
144
|
+
this.state.setSession(resolvedId, updatedRecord);
|
|
145
|
+
resolved.set(id, resolvedId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
204
148
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
: new Date(record.startedAt).getTime();
|
|
208
|
-
if (now - stoppedAt > STOPPED_SESSION_PRUNE_AGE_MS) {
|
|
209
|
-
this.state.removeSession(id);
|
|
210
|
-
pruned++;
|
|
149
|
+
catch {
|
|
150
|
+
// Adapter failed — skip
|
|
211
151
|
}
|
|
212
152
|
}
|
|
213
|
-
|
|
214
|
-
console.error(`Pruned ${pruned} sessions stopped >7 days ago from state`);
|
|
215
|
-
}
|
|
153
|
+
return resolved;
|
|
216
154
|
}
|
|
217
|
-
/** Track a newly launched session */
|
|
155
|
+
/** Track a newly launched session (stores launch metadata in state) */
|
|
218
156
|
track(session, adapterName) {
|
|
219
157
|
const record = sessionToRecord(session, adapterName);
|
|
220
158
|
// Pending→UUID reconciliation: if this is a real session (not pending),
|
|
@@ -229,7 +167,7 @@ export class SessionTracker {
|
|
|
229
167
|
this.state.setSession(session.id, record);
|
|
230
168
|
return record;
|
|
231
169
|
}
|
|
232
|
-
/** Get session
|
|
170
|
+
/** Get session launch metadata by id (exact or prefix match) */
|
|
233
171
|
getSession(id) {
|
|
234
172
|
// Exact match
|
|
235
173
|
const exact = this.state.getSession(id);
|
|
@@ -242,48 +180,11 @@ export class SessionTracker {
|
|
|
242
180
|
return matches[0][1];
|
|
243
181
|
return undefined;
|
|
244
182
|
}
|
|
245
|
-
/**
|
|
246
|
-
listSessions(opts) {
|
|
247
|
-
const sessions = Object.values(this.state.getSessions());
|
|
248
|
-
// Liveness check: mark sessions with dead PIDs as stopped
|
|
249
|
-
for (const s of sessions) {
|
|
250
|
-
if ((s.status === "running" || s.status === "idle") && s.pid) {
|
|
251
|
-
if (!this.isProcessAlive(s.pid)) {
|
|
252
|
-
s.status = "stopped";
|
|
253
|
-
s.stoppedAt = new Date().toISOString();
|
|
254
|
-
this.state.setSession(s.id, s);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
let filtered = sessions;
|
|
259
|
-
if (opts?.adapter) {
|
|
260
|
-
filtered = filtered.filter((s) => s.adapter === opts.adapter);
|
|
261
|
-
}
|
|
262
|
-
if (opts?.status) {
|
|
263
|
-
filtered = filtered.filter((s) => s.status === opts.status);
|
|
264
|
-
}
|
|
265
|
-
else if (!opts?.all) {
|
|
266
|
-
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
267
|
-
}
|
|
268
|
-
// Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
|
|
269
|
-
filtered = deduplicatePendingSessions(filtered);
|
|
270
|
-
return filtered.sort((a, b) => {
|
|
271
|
-
// Running first, then by recency
|
|
272
|
-
if (a.status === "running" && b.status !== "running")
|
|
273
|
-
return -1;
|
|
274
|
-
if (b.status === "running" && a.status !== "running")
|
|
275
|
-
return 1;
|
|
276
|
-
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
activeCount() {
|
|
280
|
-
return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
|
|
281
|
-
}
|
|
282
|
-
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
183
|
+
/** Remove a session from launch metadata */
|
|
283
184
|
removeSession(sessionId) {
|
|
284
185
|
this.state.removeSession(sessionId);
|
|
285
186
|
}
|
|
286
|
-
/** Called when a session stops —
|
|
187
|
+
/** Called when a session stops — marks it in launch metadata, returns the record */
|
|
287
188
|
onSessionExit(sessionId) {
|
|
288
189
|
const session = this.state.getSession(sessionId);
|
|
289
190
|
if (session) {
|
|
@@ -293,6 +194,91 @@ export class SessionTracker {
|
|
|
293
194
|
}
|
|
294
195
|
return session;
|
|
295
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Merge adapter-discovered sessions with daemon launch metadata.
|
|
199
|
+
*
|
|
200
|
+
* 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
|
|
201
|
+
* 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
|
|
202
|
+
* succeeded but didn't return them (and they're past the grace period).
|
|
203
|
+
* 3. Include recently-launched sessions that adapters haven't discovered yet.
|
|
204
|
+
*
|
|
205
|
+
* Returns the merged session list and IDs of sessions that were marked stopped
|
|
206
|
+
* (for lock cleanup by the caller).
|
|
207
|
+
*/
|
|
208
|
+
reconcileAndEnrich(discovered, succeededAdapters) {
|
|
209
|
+
// Build lookups for discovered sessions
|
|
210
|
+
const discoveredIds = new Set(discovered.map((d) => d.id));
|
|
211
|
+
const discoveredPids = new Map();
|
|
212
|
+
for (const d of discovered) {
|
|
213
|
+
if (d.pid)
|
|
214
|
+
discoveredPids.set(d.pid, d.id);
|
|
215
|
+
}
|
|
216
|
+
// 1. Convert discovered sessions to records, enriching with launch metadata
|
|
217
|
+
const sessions = discovered.map((disc) => enrichDiscovered(disc, this.state.getSession(disc.id)));
|
|
218
|
+
// 2. Reconcile daemon-launched sessions that disappeared from adapter results
|
|
219
|
+
const stoppedLaunchIds = [];
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
for (const [id, record] of Object.entries(this.state.getSessions())) {
|
|
222
|
+
if (record.status !== "running" &&
|
|
223
|
+
record.status !== "idle" &&
|
|
224
|
+
record.status !== "pending")
|
|
225
|
+
continue;
|
|
226
|
+
// If adapter for this session didn't succeed, include as-is from launch metadata
|
|
227
|
+
// (we can't verify status, so trust the last-known state)
|
|
228
|
+
if (!succeededAdapters.has(record.adapter)) {
|
|
229
|
+
sessions.push(record);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// Skip if adapter returned this session (it's still active)
|
|
233
|
+
if (discoveredIds.has(id))
|
|
234
|
+
continue;
|
|
235
|
+
// Check if this session's PID was resolved to a different ID (pending→UUID)
|
|
236
|
+
if (record.pid && discoveredPids.has(record.pid)) {
|
|
237
|
+
// PID was resolved to a real session — remove stale launch entry
|
|
238
|
+
this.state.removeSession(id);
|
|
239
|
+
stoppedLaunchIds.push(id);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// Grace period: don't mark recently-launched sessions as stopped
|
|
243
|
+
const launchAge = now - new Date(record.startedAt).getTime();
|
|
244
|
+
if (launchAge < LAUNCH_GRACE_PERIOD_MS) {
|
|
245
|
+
// Still within grace period — include as-is in results
|
|
246
|
+
sessions.push(record);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Session disappeared from adapter results — mark stopped
|
|
250
|
+
this.state.setSession(id, {
|
|
251
|
+
...record,
|
|
252
|
+
status: "stopped",
|
|
253
|
+
stoppedAt: new Date().toISOString(),
|
|
254
|
+
});
|
|
255
|
+
stoppedLaunchIds.push(id);
|
|
256
|
+
}
|
|
257
|
+
return { sessions, stoppedLaunchIds };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Check PID liveness for daemon-launched sessions.
|
|
261
|
+
* Returns IDs of sessions whose PIDs have died.
|
|
262
|
+
* This is a lightweight check (no adapter fan-out) for lock cleanup.
|
|
263
|
+
*/
|
|
264
|
+
cleanupDeadLaunches() {
|
|
265
|
+
const dead = [];
|
|
266
|
+
for (const [id, record] of Object.entries(this.state.getSessions())) {
|
|
267
|
+
if (record.status !== "running" &&
|
|
268
|
+
record.status !== "idle" &&
|
|
269
|
+
record.status !== "pending")
|
|
270
|
+
continue;
|
|
271
|
+
if (record.pid && !this.isProcessAlive(record.pid)) {
|
|
272
|
+
this.state.setSession(id, {
|
|
273
|
+
...record,
|
|
274
|
+
status: "stopped",
|
|
275
|
+
stoppedAt: new Date().toISOString(),
|
|
276
|
+
});
|
|
277
|
+
dead.push(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return dead;
|
|
281
|
+
}
|
|
296
282
|
}
|
|
297
283
|
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
298
284
|
function defaultIsProcessAlive(pid) {
|
|
@@ -305,22 +291,25 @@ function defaultIsProcessAlive(pid) {
|
|
|
305
291
|
}
|
|
306
292
|
}
|
|
307
293
|
/**
|
|
308
|
-
*
|
|
309
|
-
* This is a safety net for list output — the poll() reaper handles cleanup in state.
|
|
294
|
+
* Convert a discovered session to a SessionRecord, enriching with launch metadata.
|
|
310
295
|
*/
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
296
|
+
function enrichDiscovered(disc, launchMeta) {
|
|
297
|
+
return {
|
|
298
|
+
id: disc.id,
|
|
299
|
+
adapter: disc.adapter,
|
|
300
|
+
status: disc.status,
|
|
301
|
+
startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
302
|
+
stoppedAt: disc.stoppedAt?.toISOString(),
|
|
303
|
+
cwd: disc.cwd ?? launchMeta?.cwd,
|
|
304
|
+
model: disc.model ?? launchMeta?.model,
|
|
305
|
+
prompt: disc.prompt ?? launchMeta?.prompt,
|
|
306
|
+
tokens: disc.tokens,
|
|
307
|
+
cost: disc.cost,
|
|
308
|
+
pid: disc.pid,
|
|
309
|
+
spec: launchMeta?.spec,
|
|
310
|
+
group: launchMeta?.group,
|
|
311
|
+
meta: disc.nativeMetadata ?? launchMeta?.meta ?? {},
|
|
312
|
+
};
|
|
324
313
|
}
|
|
325
314
|
function sessionToRecord(session, adapterName) {
|
|
326
315
|
return {
|
|
@@ -340,20 +329,3 @@ function sessionToRecord(session, adapterName) {
|
|
|
340
329
|
meta: session.meta,
|
|
341
330
|
};
|
|
342
331
|
}
|
|
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/hooks.js
CHANGED
|
@@ -31,13 +31,12 @@ export async function runHook(hooks, phase, ctx) {
|
|
|
31
31
|
const result = await execAsync(script, {
|
|
32
32
|
cwd: ctx.cwd,
|
|
33
33
|
env,
|
|
34
|
-
timeout:
|
|
34
|
+
timeout: 300_000,
|
|
35
35
|
});
|
|
36
36
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
37
37
|
}
|
|
38
38
|
catch (err) {
|
|
39
39
|
const e = err;
|
|
40
|
-
|
|
41
|
-
return { stdout: e.stdout || "", stderr: e.stderr || "" };
|
|
40
|
+
throw new Error(`Hook ${phase} failed: ${e.message}`);
|
|
42
41
|
}
|
|
43
42
|
}
|