@orgloop/agentctl 1.4.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
  }
@@ -79,6 +79,10 @@ export async function startDaemon(opts = {}) {
79
79
  sessionTracker.startLaunchCleanup((deadId) => {
80
80
  lockManager.autoUnlock(deadId);
81
81
  });
82
+ // 11b. Start periodic background resolution of pending-* session IDs (10s interval)
83
+ sessionTracker.startPendingResolution((oldId, newId) => {
84
+ lockManager.updateAutoLockSessionId(oldId, newId);
85
+ });
82
86
  // 12. Create request handler
83
87
  const handleRequest = createRequestHandler({
84
88
  sessionTracker,
@@ -149,6 +153,7 @@ export async function startDaemon(opts = {}) {
149
153
  // Shutdown function
150
154
  const shutdown = async () => {
151
155
  sessionTracker.stopLaunchCleanup();
156
+ sessionTracker.stopPendingResolution();
152
157
  fuseEngine.shutdown();
153
158
  state.flush();
154
159
  await state.persist();
@@ -306,7 +311,17 @@ function createRequestHandler(ctx) {
306
311
  return sessions;
307
312
  }
308
313
  case "session.status": {
309
- const id = params.id;
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
+ }
310
325
  // Check launch metadata to determine adapter
311
326
  const launchRecord = ctx.sessionTracker.getSession(id);
312
327
  const adapterName = params.adapter || launchRecord?.adapter;
@@ -356,13 +371,21 @@ function createRequestHandler(ctx) {
356
371
  }
357
372
  case "session.peek": {
358
373
  // Auto-detect adapter from tracked session, fall back to param or claude-code
359
- 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
+ }
360
385
  const adapterName = params.adapter || tracked?.adapter || "claude-code";
361
386
  const adapter = ctx.adapters[adapterName];
362
387
  if (!adapter)
363
388
  throw new Error(`Unknown adapter: ${adapterName}`);
364
- // Use the full session ID if we resolved it from the tracker
365
- const peekId = tracked?.id || params.id;
366
389
  return adapter.peek(peekId, {
367
390
  lines: params.lines,
368
391
  });
@@ -373,7 +396,7 @@ function createRequestHandler(ctx) {
373
396
  const lock = ctx.lockManager.check(cwd);
374
397
  if (lock && !params.force) {
375
398
  if (lock.type === "manual") {
376
- 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.`);
377
400
  }
378
401
  throw new Error(`Directory in use by session ${lock.sessionId?.slice(0, 8)}. Use --force to override.`);
379
402
  }
@@ -408,14 +431,24 @@ function createRequestHandler(ctx) {
408
431
  }
409
432
  case "session.stop": {
410
433
  const id = params.id;
411
- const launchRecord = ctx.sessionTracker.getSession(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
+ }
412
445
  // Ghost pending entry with dead PID: remove from state with --force
413
- if (launchRecord?.id.startsWith("pending-") &&
446
+ if (sessionId.startsWith("pending-") &&
414
447
  params.force &&
415
- launchRecord.pid &&
448
+ launchRecord?.pid &&
416
449
  !isProcessAlive(launchRecord.pid)) {
417
- ctx.lockManager.autoUnlock(launchRecord.id);
418
- ctx.sessionTracker.removeSession(launchRecord.id);
450
+ ctx.lockManager.autoUnlock(sessionId);
451
+ ctx.sessionTracker.removeSession(sessionId);
419
452
  return null;
420
453
  }
421
454
  const adapterName = params.adapter || launchRecord?.adapter;
@@ -424,7 +457,6 @@ function createRequestHandler(ctx) {
424
457
  const adapter = ctx.adapters[adapterName];
425
458
  if (!adapter)
426
459
  throw new Error(`Unknown adapter: ${adapterName}`);
427
- const sessionId = launchRecord?.id || id;
428
460
  await adapter.stop(sessionId, {
429
461
  force: params.force,
430
462
  });
@@ -439,14 +471,24 @@ function createRequestHandler(ctx) {
439
471
  }
440
472
  case "session.resume": {
441
473
  const id = params.id;
442
- const launchRecord = ctx.sessionTracker.getSession(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
+ }
443
485
  const adapterName = params.adapter || launchRecord?.adapter;
444
486
  if (!adapterName)
445
487
  throw new Error(`Session not found: ${id}. Specify --adapter to resume a non-daemon session.`);
446
488
  const adapter = ctx.adapters[adapterName];
447
489
  if (!adapter)
448
490
  throw new Error(`Unknown adapter: ${adapterName}`);
449
- await adapter.resume(launchRecord?.id || id, params.message);
491
+ await adapter.resume(resumeId, params.message);
450
492
  return null;
451
493
  }
452
494
  // --- Prune command (#40) --- kept for CLI backward compat
@@ -20,6 +20,7 @@ export declare class SessionTracker {
20
20
  private adapters;
21
21
  private readonly isProcessAlive;
22
22
  private cleanupHandle;
23
+ private pendingResolutionHandle;
23
24
  constructor(state: StateManager, opts: SessionTrackerOpts);
24
25
  /**
25
26
  * Start periodic PID liveness check for daemon-launched sessions.
@@ -28,6 +29,23 @@ export declare class SessionTracker {
28
29
  */
29
30
  startLaunchCleanup(onDead?: (sessionId: string) => void): void;
30
31
  stopLaunchCleanup(): void;
32
+ /**
33
+ * Start periodic background resolution of pending-* session IDs.
34
+ * Runs every 10s, discovers real UUIDs via adapter PID matching.
35
+ */
36
+ startPendingResolution(onResolved?: (oldId: string, newId: string) => void): void;
37
+ stopPendingResolution(): void;
38
+ /**
39
+ * Resolve a single pending-* session ID on demand.
40
+ * Returns the resolved real UUID, or the original ID if resolution fails.
41
+ */
42
+ resolvePendingId(id: string): Promise<string>;
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.
47
+ */
48
+ resolvePendingSessions(): Promise<Map<string, string>>;
31
49
  /** Track a newly launched session (stores launch metadata in state) */
32
50
  track(session: AgentSession, adapterName: string): SessionRecord;
33
51
  /** Get session launch metadata by id (exact or prefix match) */
@@ -20,6 +20,7 @@ export class SessionTracker {
20
20
  adapters;
21
21
  isProcessAlive;
22
22
  cleanupHandle = null;
23
+ pendingResolutionHandle = null;
23
24
  constructor(state, opts) {
24
25
  this.state = state;
25
26
  this.adapters = opts.adapters;
@@ -47,6 +48,110 @@ export class SessionTracker {
47
48
  this.cleanupHandle = null;
48
49
  }
49
50
  }
51
+ /**
52
+ * Start periodic background resolution of pending-* session IDs.
53
+ * Runs every 10s, discovers real UUIDs via adapter PID matching.
54
+ */
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);
63
+ }
64
+ }
65
+ }, 10_000);
66
+ }
67
+ stopPendingResolution() {
68
+ if (this.pendingResolutionHandle) {
69
+ clearInterval(this.pendingResolutionHandle);
70
+ this.pendingResolutionHandle = null;
71
+ }
72
+ }
73
+ /**
74
+ * Resolve a single pending-* session ID on demand.
75
+ * Returns the resolved real UUID, or the original ID if resolution fails.
76
+ */
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;
95
+ }
96
+ }
97
+ catch {
98
+ // Adapter failed — return original ID
99
+ }
100
+ return id;
101
+ }
102
+ /**
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.
106
+ */
107
+ async resolvePendingSessions() {
108
+ const resolved = new Map();
109
+ const sessions = this.state.getSessions();
110
+ // Group pending sessions by adapter
111
+ const pendingByAdapter = new Map();
112
+ for (const [id, record] of Object.entries(sessions)) {
113
+ if (!id.startsWith("pending-"))
114
+ continue;
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);
122
+ }
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)
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
+ }
148
+ }
149
+ catch {
150
+ // Adapter failed — skip
151
+ }
152
+ }
153
+ return resolved;
154
+ }
50
155
  /** Track a newly launched session (stores launch metadata in state) */
51
156
  track(session, adapterName) {
52
157
  const record = sessionToRecord(session, adapterName);
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.4.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": {