@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 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, 5000);
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
- program
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}: ${existing.reason}`);
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
  }
@@ -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
- constructor(sessionTracker: SessionTracker, lockManager: LockManager, fuseEngine: FuseEngine);
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;
@@ -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
- constructor(sessionTracker, lockManager, fuseEngine) {
11
- this.sessionTracker = sessionTracker;
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.sessionTracker.activeCount());
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"');
@@ -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(sessionTracker, lockManager, fuseEngine);
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. Validate all sessions on startup mark dead ones as stopped (#40)
69
- sessionTracker.validateAllSessions();
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 session polling
73
- sessionTracker.startPolling();
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.stopPolling();
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
- let sessions = ctx.sessionTracker.listSessions({
251
- status: params.status,
252
- all: params.all,
253
- });
254
- if (params.group) {
255
- sessions = sessions.filter((s) => s.group === params.group);
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
- const session = ctx.sessionTracker.getSession(params.id);
261
- if (!session)
262
- throw new Error(`Session not found: ${params.id}`);
263
- return session;
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
- const tracked = ctx.sessionTracker.getSession(params.id);
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}: ${lock.reason}. Use --force to override.`);
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 session = ctx.sessionTracker.getSession(params.id);
319
- if (!session)
320
- throw new Error(`Session not found: ${params.id}`);
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 (session.id.startsWith("pending-") &&
446
+ if (sessionId.startsWith("pending-") &&
323
447
  params.force &&
324
- session.pid &&
325
- !isProcessAlive(session.pid)) {
326
- ctx.lockManager.autoUnlock(session.id);
327
- ctx.sessionTracker.removeSession(session.id);
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 adapter = ctx.adapters[session.adapter];
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: ${session.adapter}`);
333
- await adapter.stop(session.id, {
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(session.id);
338
- // Mark stopped
339
- const stopped = ctx.sessionTracker.onSessionExit(session.id);
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 session = ctx.sessionTracker.getSession(params.id);
347
- if (!session)
348
- throw new Error(`Session not found: ${params.id}`);
349
- const adapter = ctx.adapters[session.adapter];
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: ${session.adapter}`);
352
- await adapter.resume(session.id, params.message);
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
- const pruned = ctx.sessionTracker.pruneDeadSessions();
358
- return { pruned };
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.sessionTracker.activeCount(),
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
- * Clean up ghost sessions in the daemon state:
24
- * - pending-* entries whose PID matches a resolved session remove pending
25
- * - Any "running"/"idle" session in state whose PID is dead → mark stopped
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
- private reapStaleEntries;
30
+ startLaunchCleanup(onDead?: (sessionId: string) => void): void;
31
+ stopLaunchCleanup(): void;
28
32
  /**
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
+ * Start periodic background resolution of pending-* session IDs.
34
+ * Runs every 10s, discovers real UUIDs via adapter PID matching.
33
35
  */
34
- validateAllSessions(): void;
36
+ startPendingResolution(onResolved?: (oldId: string, newId: string) => void): void;
37
+ stopPendingResolution(): void;
35
38
  /**
36
- * Aggressively prune all clearly-dead sessions (#40).
37
- * Returns the number of sessions pruned.
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
- pruneDeadSessions(): number;
42
+ resolvePendingId(id: string): Promise<string>;
41
43
  /**
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
+ * 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
- private pruneOldSessions;
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 record by id (exact or prefix) */
51
+ /** Get session launch metadata by id (exact or prefix match) */
49
52
  getSession(id: string): SessionRecord | undefined;
50
- /** List all tracked sessions */
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 — returns the cwd for fuse/lock processing */
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
- /** Max age for stopped sessions in state before pruning (7 days) */
2
- const STOPPED_SESSION_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
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
- startPolling() {
17
- if (this.pollHandle)
18
- return;
19
- // Prune old stopped sessions on startup
20
- this.pruneOldSessions();
21
- // Initial poll
22
- this.guardedPoll();
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.polling = true;
32
- this.poll()
33
- .catch((err) => console.error("Poll error:", err))
34
- .finally(() => {
35
- this.polling = false;
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
- * Clean up ghost sessions in the daemon state:
85
- * - pending-* entries whose PID matches a resolved session → remove pending
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
- reapStaleEntries(adapterPidToId) {
89
- const sessions = this.state.getSessions();
90
- for (const [id, record] of Object.entries(sessions)) {
91
- // Bug 2: If this is a pending-* entry and a real session has the same PID,
92
- // the pending entry is stale — remove it
93
- if (id.startsWith("pending-") && record.pid) {
94
- const resolvedId = adapterPidToId.get(record.pid);
95
- if (resolvedId && resolvedId !== id) {
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
- * 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.
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
- 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++;
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
- if (cleaned > 0) {
151
- console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
97
+ catch {
98
+ // Adapter failed return original ID
152
99
  }
100
+ return id;
153
101
  }
154
102
  /**
155
- * Aggressively prune all clearly-dead sessions (#40).
156
- * Returns the number of sessions pruned.
157
- * Called via `agentctl prune` command.
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
- pruneDeadSessions() {
107
+ async resolvePendingSessions() {
108
+ const resolved = new Map();
160
109
  const sessions = this.state.getSessions();
161
- let pruned = 0;
110
+ // Group pending sessions by adapter
111
+ const pendingByAdapter = new Map();
162
112
  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
- }
113
+ if (!id.startsWith("pending-"))
175
114
  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
- }
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
- return pruned;
190
- }
191
- /**
192
- * Remove stopped sessions from state that have been stopped for more than 7 days.
193
- * This reduces overhead from accumulating hundreds of historical sessions.
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
- const stoppedAt = record.stoppedAt
206
- ? new Date(record.stoppedAt).getTime()
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
- if (pruned > 0) {
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 record by id (exact or prefix) */
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
- /** List all tracked sessions */
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 — returns the cwd for fuse/lock processing */
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
- * Remove pending-* entries that share a PID with a resolved (non-pending) session.
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 deduplicatePendingSessions(sessions) {
312
- const realPids = new Set();
313
- for (const s of sessions) {
314
- if (!s.id.startsWith("pending-") && s.pid) {
315
- realPids.add(s.pid);
316
- }
317
- }
318
- return sessions.filter((s) => {
319
- if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
320
- return false;
321
- }
322
- return true;
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: 60_000,
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
- console.error(`Hook ${phase} failed:`, e.message);
41
- return { stdout: e.stdout || "", stderr: e.stderr || "" };
40
+ throw new Error(`Hook ${phase} failed: ${e.message}`);
42
41
  }
43
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orgloop/agentctl",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
5
  "type": "module",
6
6
  "bin": {