@orgloop/agentctl 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,21 +9,34 @@ import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { Command } from "commander";
11
11
  import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
12
+ import { CodexAdapter } from "./adapters/codex.js";
12
13
  import { OpenClawAdapter } from "./adapters/openclaw.js";
14
+ import { OpenCodeAdapter } from "./adapters/opencode.js";
15
+ import { PiAdapter } from "./adapters/pi.js";
16
+ import { PiRustAdapter } from "./adapters/pi-rust.js";
13
17
  import { DaemonClient } from "./client/daemon-client.js";
14
18
  import { runHook } from "./hooks.js";
19
+ import { orchestrateLaunch, parseAdapterSlots, } from "./launch-orchestrator.js";
20
+ import { expandMatrix, parseMatrixFile } from "./matrix-parser.js";
15
21
  import { mergeSession } from "./merge.js";
16
22
  import { createWorktree } from "./worktree.js";
17
23
  const adapters = {
18
24
  "claude-code": new ClaudeCodeAdapter(),
25
+ codex: new CodexAdapter(),
19
26
  openclaw: new OpenClawAdapter(),
27
+ opencode: new OpenCodeAdapter(),
28
+ pi: new PiAdapter(),
29
+ "pi-rust": new PiRustAdapter(),
20
30
  };
21
31
  const client = new DaemonClient();
22
32
  /**
23
33
  * Ensure the daemon is running. Auto-starts it if not.
24
34
  * Returns true if daemon is available after the call.
35
+ * Set AGENTCTL_NO_DAEMON=1 to skip daemon and use direct adapter mode.
25
36
  */
26
37
  async function ensureDaemon() {
38
+ if (process.env.AGENTCTL_NO_DAEMON === "1")
39
+ return false;
27
40
  if (await client.isRunning())
28
41
  return true;
29
42
  // Auto-start daemon in background
@@ -67,27 +80,35 @@ function getAllAdapters() {
67
80
  return Object.values(adapters);
68
81
  }
69
82
  // --- Formatters ---
70
- function formatSession(s) {
71
- return {
83
+ function formatSession(s, showGroup) {
84
+ const row = {
72
85
  ID: s.id.slice(0, 8),
73
86
  Status: s.status,
87
+ Adapter: s.adapter || "-",
74
88
  Model: s.model || "-",
75
- CWD: s.cwd ? shortenPath(s.cwd) : "-",
76
- PID: s.pid?.toString() || "-",
77
- Started: timeAgo(s.startedAt),
78
- Prompt: (s.prompt || "-").slice(0, 60),
79
89
  };
90
+ if (showGroup)
91
+ row.Group = s.group || "-";
92
+ row.CWD = s.cwd ? shortenPath(s.cwd) : "-";
93
+ row.PID = s.pid?.toString() || "-";
94
+ row.Started = timeAgo(s.startedAt);
95
+ row.Prompt = (s.prompt || "-").slice(0, 60);
96
+ return row;
80
97
  }
81
- function formatRecord(s) {
82
- return {
98
+ function formatRecord(s, showGroup) {
99
+ const row = {
83
100
  ID: s.id.slice(0, 8),
84
101
  Status: s.status,
102
+ Adapter: s.adapter || "-",
85
103
  Model: s.model || "-",
86
- CWD: s.cwd ? shortenPath(s.cwd) : "-",
87
- PID: s.pid?.toString() || "-",
88
- Started: timeAgo(new Date(s.startedAt)),
89
- Prompt: (s.prompt || "-").slice(0, 60),
90
104
  };
105
+ if (showGroup)
106
+ row.Group = s.group || "-";
107
+ row.CWD = s.cwd ? shortenPath(s.cwd) : "-";
108
+ row.PID = s.pid?.toString() || "-";
109
+ row.Started = timeAgo(new Date(s.startedAt));
110
+ row.Prompt = (s.prompt || "-").slice(0, 60);
111
+ return row;
91
112
  }
92
113
  function shortenPath(p) {
93
114
  const home = process.env.HOME || "";
@@ -153,6 +174,7 @@ function sessionToJson(s) {
153
174
  tokens: s.tokens,
154
175
  cost: s.cost,
155
176
  pid: s.pid,
177
+ group: s.group,
156
178
  meta: s.meta,
157
179
  };
158
180
  }
@@ -168,20 +190,27 @@ program
168
190
  .description("List agent sessions")
169
191
  .option("--adapter <name>", "Filter by adapter")
170
192
  .option("--status <status>", "Filter by status (running|stopped|idle|error)")
193
+ .option("--group <id>", "Filter by launch group (e.g. g-a1b2c3)")
171
194
  .option("-a, --all", "Include stopped sessions (last 7 days)")
172
195
  .option("--json", "Output as JSON")
173
196
  .action(async (opts) => {
174
197
  const daemonRunning = await ensureDaemon();
175
198
  if (daemonRunning) {
176
- const sessions = await client.call("session.list", {
199
+ let sessions = await client.call("session.list", {
177
200
  status: opts.status,
178
201
  all: opts.all,
202
+ adapter: opts.adapter,
203
+ group: opts.group,
179
204
  });
205
+ if (opts.adapter) {
206
+ sessions = sessions.filter((s) => s.adapter === opts.adapter);
207
+ }
180
208
  if (opts.json) {
181
209
  printJson(sessions);
182
210
  }
183
211
  else {
184
- printTable(sessions.map(formatRecord));
212
+ const hasGroups = sessions.some((s) => s.group);
213
+ printTable(sessions.map((s) => formatRecord(s, hasGroups)));
185
214
  }
186
215
  return;
187
216
  }
@@ -202,7 +231,8 @@ program
202
231
  printJson(sessions.map(sessionToJson));
203
232
  }
204
233
  else {
205
- printTable(sessions.map(formatSession));
234
+ const hasGroups = sessions.some((s) => s.group);
235
+ printTable(sessions.map((s) => formatSession(s, hasGroups)));
206
236
  }
207
237
  });
208
238
  // status
@@ -222,7 +252,7 @@ program
222
252
  printJson(session);
223
253
  }
224
254
  else {
225
- const fmt = formatRecord(session);
255
+ const fmt = formatRecord(session, !!session.group);
226
256
  for (const [k, v] of Object.entries(fmt)) {
227
257
  console.log(`${k.padEnd(10)} ${v}`);
228
258
  }
@@ -232,32 +262,37 @@ program
232
262
  }
233
263
  return;
234
264
  }
235
- catch (err) {
236
- console.error(err.message);
237
- process.exit(1);
265
+ catch {
266
+ // Daemon failed — fall through to direct adapter lookup
238
267
  }
239
268
  }
240
- // Direct fallback
241
- const adapter = getAdapter(opts.adapter);
242
- try {
243
- const session = await adapter.status(id);
244
- if (opts.json) {
245
- printJson(sessionToJson(session));
246
- }
247
- else {
248
- const fmt = formatSession(session);
249
- for (const [k, v] of Object.entries(fmt)) {
250
- console.log(`${k.padEnd(10)} ${v}`);
269
+ // Direct fallback: try specified adapter, or search all adapters
270
+ const statusAdapters = opts.adapter
271
+ ? [getAdapter(opts.adapter)]
272
+ : getAllAdapters();
273
+ for (const adapter of statusAdapters) {
274
+ try {
275
+ const session = await adapter.status(id);
276
+ if (opts.json) {
277
+ printJson(sessionToJson(session));
251
278
  }
252
- if (session.tokens) {
253
- console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
279
+ else {
280
+ const fmt = formatSession(session, !!session.group);
281
+ for (const [k, v] of Object.entries(fmt)) {
282
+ console.log(`${k.padEnd(10)} ${v}`);
283
+ }
284
+ if (session.tokens) {
285
+ console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
286
+ }
254
287
  }
288
+ return;
289
+ }
290
+ catch {
291
+ // Try next adapter
255
292
  }
256
293
  }
257
- catch (err) {
258
- console.error(err.message);
259
- process.exit(1);
260
- }
294
+ console.error(`Session not found: ${id}`);
295
+ process.exit(1);
261
296
  });
262
297
  // peek
263
298
  program
@@ -276,22 +311,39 @@ program
276
311
  console.log(output);
277
312
  return;
278
313
  }
314
+ catch {
315
+ // Daemon failed — fall through to direct adapter lookup
316
+ }
317
+ }
318
+ // Direct fallback: try specified adapter, or search all adapters
319
+ if (opts.adapter) {
320
+ const adapter = getAdapter(opts.adapter);
321
+ try {
322
+ const output = await adapter.peek(id, {
323
+ lines: Number.parseInt(opts.lines, 10),
324
+ });
325
+ console.log(output);
326
+ return;
327
+ }
279
328
  catch (err) {
280
329
  console.error(err.message);
281
330
  process.exit(1);
282
331
  }
283
332
  }
284
- const adapter = getAdapter(opts.adapter);
285
- try {
286
- const output = await adapter.peek(id, {
287
- lines: Number.parseInt(opts.lines, 10),
288
- });
289
- console.log(output);
290
- }
291
- catch (err) {
292
- console.error(err.message);
293
- process.exit(1);
333
+ for (const adapter of getAllAdapters()) {
334
+ try {
335
+ const output = await adapter.peek(id, {
336
+ lines: Number.parseInt(opts.lines, 10),
337
+ });
338
+ console.log(output);
339
+ return;
340
+ }
341
+ catch {
342
+ // Try next adapter
343
+ }
294
344
  }
345
+ console.error(`Session not found: ${id}`);
346
+ process.exit(1);
295
347
  });
296
348
  // stop
297
349
  program
@@ -356,7 +408,7 @@ program
356
408
  // launch
357
409
  program
358
410
  .command("launch [adapter]")
359
- .description("Launch a new agent session")
411
+ .description("Launch a new agent session (or multiple with --adapter flags)")
360
412
  .requiredOption("-p, --prompt <text>", "Prompt to send")
361
413
  .option("--spec <path>", "Spec file path")
362
414
  .option("--cwd <dir>", "Working directory")
@@ -364,13 +416,108 @@ program
364
416
  .option("--force", "Override directory locks")
365
417
  .option("--worktree <repo>", "Auto-create git worktree from this repo before launch")
366
418
  .option("--branch <name>", "Branch name for --worktree")
419
+ .option("--adapter <name>", "Adapter to launch (repeatable for parallel launch)", collectAdapter, [])
420
+ .option("--matrix <file>", "YAML matrix file for advanced sweep launch")
367
421
  .option("--on-create <script>", "Hook: run after session is created")
368
422
  .option("--on-complete <script>", "Hook: run after session completes")
369
423
  .option("--pre-merge <script>", "Hook: run before merge")
370
424
  .option("--post-merge <script>", "Hook: run after merge")
425
+ .allowUnknownOption() // Allow interleaved --adapter/--model for parseAdapterSlots
371
426
  .action(async (adapterName, opts) => {
372
427
  let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
373
- const name = adapterName || "claude-code";
428
+ // Collect hooks
429
+ const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
430
+ ? {
431
+ onCreate: opts.onCreate,
432
+ onComplete: opts.onComplete,
433
+ preMerge: opts.preMerge,
434
+ postMerge: opts.postMerge,
435
+ }
436
+ : undefined;
437
+ // --- Multi-adapter / matrix detection ---
438
+ let slots = [];
439
+ if (opts.matrix) {
440
+ // Matrix file mode
441
+ try {
442
+ const matrixFile = await parseMatrixFile(opts.matrix);
443
+ slots = expandMatrix(matrixFile);
444
+ // Matrix can override cwd and prompt
445
+ if (matrixFile.cwd)
446
+ cwd = path.resolve(matrixFile.cwd);
447
+ }
448
+ catch (err) {
449
+ console.error(`Failed to parse matrix file: ${err.message}`);
450
+ process.exit(1);
451
+ }
452
+ }
453
+ else {
454
+ // Check for multi-adapter via raw argv parsing
455
+ // We need raw argv because commander can't handle interleaved
456
+ // --adapter A --model M1 --adapter B --model M2
457
+ const rawArgs = process.argv.slice(2);
458
+ const adapterCount = rawArgs.filter((a) => a === "--adapter" || a === "-A").length;
459
+ if (adapterCount > 1) {
460
+ // Multi-adapter mode: parse from raw args
461
+ slots = parseAdapterSlots(rawArgs);
462
+ }
463
+ else if (adapterCount === 1 && opts.adapter?.length === 1) {
464
+ // Single --adapter flag — could still be multi if model is specified
465
+ // but this is the normal single-adapter path via --adapter flag
466
+ }
467
+ }
468
+ // --- Parallel launch path ---
469
+ if (slots.length > 1) {
470
+ const daemonRunning = await ensureDaemon();
471
+ try {
472
+ let groupId = "";
473
+ const result = await orchestrateLaunch({
474
+ slots,
475
+ prompt: opts.prompt,
476
+ spec: opts.spec,
477
+ cwd,
478
+ hooks,
479
+ adapters,
480
+ onSessionLaunched: (slotResult) => {
481
+ // Track in daemon if available
482
+ if (daemonRunning && !slotResult.error) {
483
+ client
484
+ .call("session.launch.track", {
485
+ id: slotResult.sessionId,
486
+ adapter: slotResult.slot.adapter,
487
+ cwd: slotResult.cwd,
488
+ group: groupId,
489
+ })
490
+ .catch(() => {
491
+ // Best effort — session will be picked up by poll
492
+ });
493
+ }
494
+ },
495
+ onGroupCreated: (id) => {
496
+ groupId = id;
497
+ },
498
+ });
499
+ console.log(`\nLaunched ${result.results.length} sessions (group: ${result.groupId}):`);
500
+ for (const r of result.results) {
501
+ const label = r.slot.model
502
+ ? `${r.slot.adapter} (${r.slot.model})`
503
+ : r.slot.adapter;
504
+ if (r.error) {
505
+ console.log(` ✗ ${label} — ${r.error}`);
506
+ }
507
+ else {
508
+ console.log(` ${label} → ${shortenPath(r.cwd)} (${r.sessionId.slice(0, 8)})`);
509
+ }
510
+ }
511
+ }
512
+ catch (err) {
513
+ console.error(`Parallel launch failed: ${err.message}`);
514
+ process.exit(1);
515
+ }
516
+ return;
517
+ }
518
+ // --- Single adapter launch path (original behavior) ---
519
+ const name = slots.length === 1 ? slots[0].adapter : adapterName || "claude-code";
520
+ const model = slots.length === 1 && slots[0].model ? slots[0].model : opts.model;
374
521
  // FEAT-1: Worktree lifecycle
375
522
  let worktreeInfo;
376
523
  if (opts.worktree) {
@@ -391,15 +538,6 @@ program
391
538
  process.exit(1);
392
539
  }
393
540
  }
394
- // Collect hooks
395
- const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
396
- ? {
397
- onCreate: opts.onCreate,
398
- onComplete: opts.onComplete,
399
- preMerge: opts.preMerge,
400
- postMerge: opts.postMerge,
401
- }
402
- : undefined;
403
541
  const daemonRunning = await ensureDaemon();
404
542
  if (daemonRunning) {
405
543
  try {
@@ -408,7 +546,7 @@ program
408
546
  prompt: opts.prompt,
409
547
  cwd,
410
548
  spec: opts.spec,
411
- model: opts.model,
549
+ model,
412
550
  force: opts.force,
413
551
  worktree: worktreeInfo
414
552
  ? { repo: worktreeInfo.repo, branch: worktreeInfo.branch }
@@ -443,7 +581,7 @@ program
443
581
  prompt: opts.prompt,
444
582
  spec: opts.spec,
445
583
  cwd,
446
- model: opts.model,
584
+ model,
447
585
  hooks,
448
586
  });
449
587
  console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`);
@@ -462,6 +600,10 @@ program
462
600
  process.exit(1);
463
601
  }
464
602
  });
603
+ /** Commander collect callback for repeatable --adapter */
604
+ function collectAdapter(value, previous) {
605
+ return previous.concat([value]);
606
+ }
465
607
  // events
466
608
  program
467
609
  .command("events")
@@ -560,6 +702,84 @@ program
560
702
  });
561
703
  }
562
704
  });
705
+ // --- Worktree subcommand ---
706
+ const worktreeCmd = new Command("worktree").description("Manage agentctl-created worktrees");
707
+ worktreeCmd
708
+ .command("list")
709
+ .description("List git worktrees for a repo")
710
+ .argument("<repo>", "Path to the main repo")
711
+ .option("--json", "Output as JSON")
712
+ .action(async (repo, opts) => {
713
+ const { listWorktrees } = await import("./worktree.js");
714
+ try {
715
+ const entries = await listWorktrees(repo);
716
+ // Filter to only non-bare worktrees (exclude the main worktree)
717
+ const worktrees = entries.filter((e) => !e.bare);
718
+ if (opts.json) {
719
+ printJson(worktrees);
720
+ return;
721
+ }
722
+ if (worktrees.length === 0) {
723
+ console.log("No worktrees found.");
724
+ return;
725
+ }
726
+ printTable(worktrees.map((e) => ({
727
+ Path: shortenPath(e.path),
728
+ Branch: e.branch || "-",
729
+ HEAD: e.head?.slice(0, 8) || "-",
730
+ })));
731
+ }
732
+ catch (err) {
733
+ console.error(err.message);
734
+ process.exit(1);
735
+ }
736
+ });
737
+ worktreeCmd
738
+ .command("clean")
739
+ .description("Remove a worktree and optionally its branch")
740
+ .argument("<path>", "Path to the worktree to remove")
741
+ .option("--repo <path>", "Main repo path (auto-detected if omitted)")
742
+ .option("--delete-branch", "Also delete the worktree's branch")
743
+ .action(async (worktreePath, opts) => {
744
+ const { cleanWorktree } = await import("./worktree.js");
745
+ const absPath = path.resolve(worktreePath);
746
+ let repo = opts.repo;
747
+ // Auto-detect repo from the worktree's .git file
748
+ if (!repo) {
749
+ try {
750
+ const gitFile = await fs.readFile(path.join(absPath, ".git"), "utf-8");
751
+ // .git file contains: gitdir: /path/to/repo/.git/worktrees/<name>
752
+ const match = gitFile.match(/gitdir:\s*(.+)/);
753
+ if (match) {
754
+ const gitDir = match[1].trim();
755
+ // Navigate up from .git/worktrees/<name> to the repo root
756
+ repo = path.resolve(gitDir, "..", "..", "..");
757
+ }
758
+ }
759
+ catch {
760
+ console.error("Cannot auto-detect repo. Use --repo to specify the main repository.");
761
+ process.exit(1);
762
+ }
763
+ }
764
+ if (!repo) {
765
+ console.error("Cannot determine repo path. Use --repo.");
766
+ process.exit(1);
767
+ }
768
+ try {
769
+ const result = await cleanWorktree(repo, absPath, {
770
+ deleteBranch: opts.deleteBranch,
771
+ });
772
+ console.log(`Removed worktree: ${shortenPath(result.removedPath)}`);
773
+ if (result.deletedBranch) {
774
+ console.log(`Deleted branch: ${result.deletedBranch}`);
775
+ }
776
+ }
777
+ catch (err) {
778
+ console.error(err.message);
779
+ process.exit(1);
780
+ }
781
+ });
782
+ program.addCommand(worktreeCmd);
563
783
  // --- Lock commands ---
564
784
  program
565
785
  .command("lock <directory>")
@@ -650,6 +870,25 @@ program
650
870
  process.exit(1);
651
871
  }
652
872
  });
873
+ // --- Prune command (#40) ---
874
+ program
875
+ .command("prune")
876
+ .description("Remove dead and stale sessions from daemon state")
877
+ .action(async () => {
878
+ const daemonRunning = await ensureDaemon();
879
+ if (!daemonRunning) {
880
+ console.error("Daemon not running. Start with: agentctl daemon start");
881
+ process.exit(1);
882
+ }
883
+ try {
884
+ const result = await client.call("session.prune");
885
+ console.log(`Pruned ${result.pruned} dead/stale sessions`);
886
+ }
887
+ catch (err) {
888
+ console.error(err.message);
889
+ process.exit(1);
890
+ }
891
+ });
653
892
  // --- Daemon subcommand ---
654
893
  const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
655
894
  daemonCmd
@@ -727,8 +966,9 @@ daemonCmd
727
966
  });
728
967
  daemonCmd
729
968
  .command("status")
730
- .description("Show daemon status")
969
+ .description("Show daemon status and all daemon-related processes")
731
970
  .action(async () => {
971
+ // Show daemon status
732
972
  try {
733
973
  const status = await client.call("daemon.status");
734
974
  console.log(`Daemon running (PID ${status.pid})`);
@@ -740,6 +980,38 @@ daemonCmd
740
980
  catch {
741
981
  console.log("Daemon not running");
742
982
  }
983
+ // Show all daemon-related processes (#39)
984
+ const configDir = path.join(os.homedir(), ".agentctl");
985
+ const { getSupervisorPid } = await import("./daemon/supervisor.js");
986
+ const supPid = await getSupervisorPid();
987
+ let daemonPid = null;
988
+ try {
989
+ const raw = await fs.readFile(path.join(configDir, "agentctl.pid"), "utf-8");
990
+ const pid = Number.parseInt(raw.trim(), 10);
991
+ try {
992
+ process.kill(pid, 0);
993
+ daemonPid = pid;
994
+ }
995
+ catch {
996
+ // PID file is stale
997
+ }
998
+ }
999
+ catch {
1000
+ // No PID file
1001
+ }
1002
+ console.log("\nDaemon-related processes:");
1003
+ if (supPid) {
1004
+ console.log(` Supervisor: PID ${supPid} (alive)`);
1005
+ }
1006
+ else {
1007
+ console.log(" Supervisor: not running");
1008
+ }
1009
+ if (daemonPid) {
1010
+ console.log(` Daemon: PID ${daemonPid} (alive)`);
1011
+ }
1012
+ else {
1013
+ console.log(" Daemon: not running");
1014
+ }
743
1015
  });
744
1016
  daemonCmd
745
1017
  .command("restart")
@@ -24,6 +24,7 @@ export interface AgentSession {
24
24
  };
25
25
  cost?: number;
26
26
  pid?: number;
27
+ group?: string;
27
28
  meta: Record<string, unknown>;
28
29
  }
29
30
  export interface LifecycleEvent {