@os-eco/overstory-cli 0.9.1 → 0.9.3

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.
Files changed (101) hide show
  1. package/README.md +21 -6
  2. package/agents/coordinator.md +34 -10
  3. package/agents/lead.md +11 -1
  4. package/package.json +1 -1
  5. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  6. package/src/agents/copilot-hooks-deployer.ts +93 -0
  7. package/src/agents/hooks-deployer.test.ts +9 -1
  8. package/src/agents/hooks-deployer.ts +2 -1
  9. package/src/agents/overlay.test.ts +26 -0
  10. package/src/agents/overlay.ts +18 -4
  11. package/src/beads/client.ts +31 -3
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +228 -125
  19. package/src/commands/dashboard.ts +50 -10
  20. package/src/commands/doctor.ts +3 -1
  21. package/src/commands/ecosystem.test.ts +126 -1
  22. package/src/commands/ecosystem.ts +7 -53
  23. package/src/commands/feed.test.ts +117 -2
  24. package/src/commands/feed.ts +46 -30
  25. package/src/commands/group.test.ts +274 -155
  26. package/src/commands/group.ts +11 -5
  27. package/src/commands/init.ts +50 -0
  28. package/src/commands/inspect.ts +8 -4
  29. package/src/commands/log.test.ts +35 -0
  30. package/src/commands/log.ts +10 -6
  31. package/src/commands/logs.test.ts +423 -1
  32. package/src/commands/logs.ts +99 -104
  33. package/src/commands/monitor.ts +8 -2
  34. package/src/commands/orchestrator.ts +42 -0
  35. package/src/commands/prime.test.ts +177 -2
  36. package/src/commands/prime.ts +4 -2
  37. package/src/commands/sling.ts +8 -3
  38. package/src/commands/upgrade.test.ts +2 -0
  39. package/src/commands/upgrade.ts +1 -17
  40. package/src/commands/watch.test.ts +67 -1
  41. package/src/commands/watch.ts +4 -79
  42. package/src/config.test.ts +250 -0
  43. package/src/config.ts +43 -0
  44. package/src/doctor/agents.test.ts +72 -5
  45. package/src/doctor/agents.ts +10 -10
  46. package/src/doctor/consistency.test.ts +35 -0
  47. package/src/doctor/consistency.ts +7 -3
  48. package/src/doctor/dependencies.test.ts +58 -1
  49. package/src/doctor/dependencies.ts +4 -2
  50. package/src/doctor/providers.test.ts +41 -5
  51. package/src/doctor/types.ts +2 -1
  52. package/src/doctor/version.test.ts +106 -2
  53. package/src/doctor/version.ts +4 -2
  54. package/src/doctor/watchdog.test.ts +167 -0
  55. package/src/doctor/watchdog.ts +158 -0
  56. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  57. package/src/errors.test.ts +350 -0
  58. package/src/events/tailer.test.ts +25 -0
  59. package/src/events/tailer.ts +8 -1
  60. package/src/index.ts +4 -1
  61. package/src/mail/store.test.ts +110 -0
  62. package/src/runtimes/aider.test.ts +124 -0
  63. package/src/runtimes/aider.ts +147 -0
  64. package/src/runtimes/amp.test.ts +164 -0
  65. package/src/runtimes/amp.ts +154 -0
  66. package/src/runtimes/claude.test.ts +4 -2
  67. package/src/runtimes/codex.test.ts +38 -1
  68. package/src/runtimes/codex.ts +22 -3
  69. package/src/runtimes/copilot.test.ts +213 -13
  70. package/src/runtimes/copilot.ts +93 -11
  71. package/src/runtimes/goose.test.ts +133 -0
  72. package/src/runtimes/goose.ts +157 -0
  73. package/src/runtimes/pi-guards.ts +2 -1
  74. package/src/runtimes/pi.test.ts +33 -9
  75. package/src/runtimes/pi.ts +10 -10
  76. package/src/runtimes/registry.test.ts +1 -1
  77. package/src/runtimes/registry.ts +13 -4
  78. package/src/runtimes/sapling.ts +2 -1
  79. package/src/runtimes/types.ts +9 -2
  80. package/src/tracker/factory.test.ts +10 -0
  81. package/src/tracker/factory.ts +3 -2
  82. package/src/types.ts +4 -0
  83. package/src/utils/bin.test.ts +10 -0
  84. package/src/utils/bin.ts +37 -0
  85. package/src/utils/fs.test.ts +119 -0
  86. package/src/utils/fs.ts +62 -0
  87. package/src/utils/pid.test.ts +68 -0
  88. package/src/utils/pid.ts +45 -0
  89. package/src/utils/time.test.ts +43 -0
  90. package/src/utils/time.ts +37 -0
  91. package/src/utils/version.test.ts +33 -0
  92. package/src/utils/version.ts +70 -0
  93. package/src/watchdog/daemon.test.ts +255 -1
  94. package/src/watchdog/daemon.ts +46 -9
  95. package/src/watchdog/health.test.ts +15 -1
  96. package/src/watchdog/health.ts +1 -1
  97. package/src/watchdog/triage.test.ts +49 -9
  98. package/src/watchdog/triage.ts +21 -5
  99. package/src/worktree/tmux.test.ts +166 -49
  100. package/src/worktree/tmux.ts +36 -37
  101. package/templates/copilot-hooks.json.tmpl +13 -0
@@ -38,6 +38,7 @@ import {
38
38
  isSessionAlive,
39
39
  killSession,
40
40
  sendKeys,
41
+ TMUX_SOCKET,
41
42
  waitForTuiReady,
42
43
  } from "../worktree/tmux.ts";
43
44
  import { nudgeAgent } from "./nudge.ts";
@@ -46,6 +47,24 @@ import { isRunningAsRoot } from "./sling.ts";
46
47
  /** Default coordinator agent name. */
47
48
  const COORDINATOR_NAME = "coordinator";
48
49
 
50
+ export interface PersistentAgentSpec {
51
+ commandName: string;
52
+ displayName: string;
53
+ agentName: string;
54
+ capability: string;
55
+ agentDefFile: string;
56
+ beaconBuilder: (trackerCli: string) => string;
57
+ }
58
+
59
+ const COORDINATOR_SPEC: PersistentAgentSpec = {
60
+ commandName: "coordinator",
61
+ displayName: "Coordinator",
62
+ agentName: COORDINATOR_NAME,
63
+ capability: "coordinator",
64
+ agentDefFile: "coordinator.md",
65
+ beaconBuilder: buildCoordinatorBeacon,
66
+ };
67
+
49
68
  /** Poll interval for the ask subcommand reply loop. */
50
69
  const ASK_POLL_INTERVAL_MS = 2_000;
51
70
 
@@ -273,8 +292,8 @@ export function buildCoordinatorBeacon(cliName = "bd"): string {
273
292
  const parts = [
274
293
  `[OVERSTORY] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
275
294
  "Depth: 0 | Parent: none | Role: persistent orchestrator",
276
- "HIERARCHY: You ONLY spawn leads (ov sling --capability lead). Leads spawn scouts, builders, reviewers. NEVER spawn non-lead agents directly.",
277
- "DELEGATION: For any exploration/scouting, spawn a lead who will spawn scouts. Do NOT explore the codebase yourself beyond initial planning.",
295
+ "HIERARCHY: Default to leads (ov sling --capability lead). For low-budget or very narrow work, you may spawn scout/builder directly. NEVER spawn reviewer or merger directly.",
296
+ "DELEGATION: For substantial work streams, spawn a lead who will handle scouts/builders/reviewers. For tight agent budgets, compress roles by using direct scout/builder fallback or --dispatch-max-agents 1/2 on the lead.",
278
297
  `Startup: run mulch prime, check mail (ov mail check --agent ${COORDINATOR_NAME}), check ${cliName} ready, check ov group status, then begin work`,
279
298
  ];
280
299
  return parts.join(" — ");
@@ -302,6 +321,14 @@ export interface CoordinatorSessionOptions {
302
321
  profile?: string;
303
322
  /** Override coordinator name (default: "coordinator"). */
304
323
  coordinatorName?: string;
324
+ /** Generic persistent agent name override. Preferred over coordinatorName for new callers. */
325
+ agentName?: string;
326
+ /** Capability stored in the session registry and used for manifest/runtime resolution. */
327
+ capability?: string;
328
+ /** Agent definition file to append as the system prompt. */
329
+ agentDefFile?: string;
330
+ /** Human-readable label for output. */
331
+ displayName?: string;
305
332
  /** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
306
333
  beaconBuilder?: (trackerCli: string) => string;
307
334
  }
@@ -332,10 +359,17 @@ export async function startCoordinatorSession(
332
359
  monitor: monitorFlag,
333
360
  profile: profileFlag,
334
361
  coordinatorName: coordinatorNameOpt,
362
+ agentName: agentNameOpt,
363
+ capability: capabilityOpt,
364
+ agentDefFile: agentDefFileOpt,
365
+ displayName: displayNameOpt,
335
366
  beaconBuilder: beaconBuilderOpt,
336
367
  } = opts;
337
368
 
338
- const coordinatorName = coordinatorNameOpt ?? COORDINATOR_NAME;
369
+ const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
370
+ const capability = capabilityOpt ?? COORDINATOR_SPEC.capability;
371
+ const agentDefFile = agentDefFileOpt ?? COORDINATOR_SPEC.agentDefFile;
372
+ const displayName = displayNameOpt ?? COORDINATOR_SPEC.displayName;
339
373
  const beaconBuilder = beaconBuilderOpt ?? buildCoordinatorBeacon;
340
374
 
341
375
  if (isRunningAsRoot()) {
@@ -359,7 +393,7 @@ export async function startCoordinatorSession(
359
393
 
360
394
  if (
361
395
  existing &&
362
- existing.capability === "coordinator" &&
396
+ existing.capability === capability &&
363
397
  existing.state !== "completed" &&
364
398
  existing.state !== "zombie"
365
399
  ) {
@@ -378,7 +412,7 @@ export async function startCoordinatorSession(
378
412
  // (e.g. sessions migrated from an older schema). In both cases we
379
413
  // cannot prove the session is a zombie, so treat it as active.
380
414
  throw new AgentError(
381
- `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
415
+ `${displayName} is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
382
416
  { agentName: coordinatorName },
383
417
  );
384
418
  }
@@ -394,8 +428,8 @@ export async function startCoordinatorSession(
394
428
  join(projectRoot, config.agents.baseDir),
395
429
  );
396
430
  const manifest = await manifestLoader.load();
397
- const resolvedModel = resolveModel(config, manifest, "coordinator", "opus");
398
- const runtime = getRuntime(undefined, config, "coordinator");
431
+ const resolvedModel = resolveModel(config, manifest, capability, "opus");
432
+ const runtime = getRuntime(undefined, config, capability);
399
433
 
400
434
  // Deploy hooks to the project root so the coordinator gets event logging,
401
435
  // mail check --inject, and activity tracking via the standard hook pipeline.
@@ -405,7 +439,7 @@ export async function startCoordinatorSession(
405
439
  // at the project root is unaffected.
406
440
  await runtime.deployConfig(projectRoot, undefined, {
407
441
  agentName: coordinatorName,
408
- capability: "coordinator",
442
+ capability,
409
443
  worktreePath: projectRoot,
410
444
  });
411
445
 
@@ -416,7 +450,7 @@ export async function startCoordinatorSession(
416
450
  if (!existingIdentity) {
417
451
  await createIdentity(identityBaseDir, {
418
452
  name: coordinatorName,
419
- capability: "coordinator",
453
+ capability,
420
454
  created: new Date().toISOString(),
421
455
  sessionsCompleted: 0,
422
456
  expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
@@ -435,10 +469,10 @@ export async function startCoordinatorSession(
435
469
  // Pass the file path (not content) so the shell inside the tmux pane reads
436
470
  // it via $(cat ...) — avoids tmux IPC "command too long" errors with large
437
471
  // agent definitions (overstory#45).
438
- const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "coordinator.md");
439
- const agentDefFile = Bun.file(agentDefPath);
472
+ const agentDefPath = join(projectRoot, ".overstory", "agent-defs", agentDefFile);
473
+ const agentDefHandle = Bun.file(agentDefPath);
440
474
  let appendSystemPromptFile: string | undefined;
441
- if (await agentDefFile.exists()) {
475
+ if (await agentDefHandle.exists()) {
442
476
  appendSystemPromptFile = agentDefPath;
443
477
  }
444
478
  const spawnCmd = runtime.buildSpawnCommand({
@@ -484,7 +518,7 @@ export async function startCoordinatorSession(
484
518
  const session: AgentSession = {
485
519
  id: sessionId,
486
520
  agentName: coordinatorName,
487
- capability: "coordinator",
521
+ capability,
488
522
  worktreePath: projectRoot, // Coordinator uses project root, not a worktree
489
523
  branchName: config.project.canonicalBranch, // Operates on canonical branch
490
524
  taskId: "", // No specific task assignment
@@ -525,14 +559,14 @@ export async function startCoordinatorSession(
525
559
  ? "The tmux server is no longer running. It may have crashed or been killed externally."
526
560
  : "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
527
561
  throw new AgentError(
528
- `Coordinator tmux session "${tmuxSession}" died during startup. ${detail}`,
562
+ `${displayName} tmux session "${tmuxSession}" died during startup. ${detail}`,
529
563
  { agentName: coordinatorName },
530
564
  );
531
565
  }
532
566
  await tmux.killSession(tmuxSession);
533
567
  store.updateState(coordinatorName, "completed");
534
568
  throw new AgentError(
535
- `Coordinator tmux session "${tmuxSession}" did not become ready during startup. Claude Code may still be waiting on an interactive dialog or initializing too slowly.`,
569
+ `${displayName} tmux session "${tmuxSession}" did not become ready during startup. Claude Code may still be waiting on an interactive dialog or initializing too slowly.`,
536
570
  { agentName: coordinatorName },
537
571
  );
538
572
  }
@@ -579,7 +613,7 @@ export async function startCoordinatorSession(
579
613
 
580
614
  const output = {
581
615
  agentName: coordinatorName,
582
- capability: "coordinator",
616
+ capability,
583
617
  tmuxSession,
584
618
  projectRoot,
585
619
  pid,
@@ -588,16 +622,16 @@ export async function startCoordinatorSession(
588
622
  };
589
623
 
590
624
  if (json) {
591
- jsonOutput("coordinator start", output);
625
+ jsonOutput(`${capability} start`, output);
592
626
  } else {
593
- printSuccess("Coordinator started");
627
+ printSuccess(`${displayName} started`);
594
628
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
595
629
  process.stdout.write(` Root: ${projectRoot}\n`);
596
630
  process.stdout.write(` PID: ${pid}\n`);
597
631
  }
598
632
 
599
633
  if (shouldAttach) {
600
- Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
634
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
601
635
  stdio: ["inherit", "inherit", "inherit"],
602
636
  });
603
637
  }
@@ -606,20 +640,36 @@ export async function startCoordinatorSession(
606
640
  }
607
641
  }
608
642
 
609
- async function startCoordinator(
643
+ async function startPersistentAgent(
644
+ spec: PersistentAgentSpec,
610
645
  opts: { json: boolean; attach: boolean; watchdog: boolean; monitor: boolean; profile?: string },
611
646
  deps: CoordinatorDeps = {},
612
647
  ): Promise<void> {
613
648
  await startCoordinatorSession(
614
649
  {
615
650
  ...opts,
616
- coordinatorName: COORDINATOR_NAME,
617
- beaconBuilder: buildCoordinatorBeacon,
651
+ agentName: spec.agentName,
652
+ capability: spec.capability,
653
+ agentDefFile: spec.agentDefFile,
654
+ displayName: spec.displayName,
655
+ beaconBuilder: spec.beaconBuilder,
618
656
  },
619
657
  deps,
620
658
  );
621
659
  }
622
660
 
661
+ function isActivePersistentAgentSession(
662
+ session: AgentSession | null,
663
+ spec: PersistentAgentSpec,
664
+ ): session is AgentSession {
665
+ return (
666
+ session !== null &&
667
+ session.capability === spec.capability &&
668
+ session.state !== "completed" &&
669
+ session.state !== "zombie"
670
+ );
671
+ }
672
+
623
673
  /**
624
674
  * Stop the coordinator agent.
625
675
  *
@@ -628,7 +678,11 @@ async function startCoordinator(
628
678
  * 3. Mark session as completed in SessionStore
629
679
  * 4. Auto-complete the active run (if current-run.txt exists)
630
680
  */
631
- async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps = {}): Promise<void> {
681
+ async function stopPersistentAgent(
682
+ spec: PersistentAgentSpec,
683
+ opts: { json: boolean },
684
+ deps: CoordinatorDeps = {},
685
+ ): Promise<void> {
632
686
  const tmux = deps._tmux ?? {
633
687
  createSession,
634
688
  isSessionAlive,
@@ -649,16 +703,11 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
649
703
  const overstoryDir = join(projectRoot, ".overstory");
650
704
  const { store } = openSessionStore(overstoryDir);
651
705
  try {
652
- const session = store.getByName(COORDINATOR_NAME);
706
+ const session = store.getByName(spec.agentName);
653
707
 
654
- if (
655
- !session ||
656
- session.capability !== "coordinator" ||
657
- session.state === "completed" ||
658
- session.state === "zombie"
659
- ) {
660
- throw new AgentError("No active coordinator session found", {
661
- agentName: COORDINATOR_NAME,
708
+ if (!isActivePersistentAgentSession(session, spec)) {
709
+ throw new AgentError(`No active ${spec.commandName} session found`, {
710
+ agentName: spec.agentName,
662
711
  });
663
712
  }
664
713
 
@@ -675,8 +724,8 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
675
724
  const monitorStopped = await monitor.stop();
676
725
 
677
726
  // Update session state
678
- store.updateState(COORDINATOR_NAME, "completed");
679
- store.updateLastActivity(COORDINATOR_NAME);
727
+ store.updateState(spec.agentName, "completed");
728
+ store.updateLastActivity(spec.agentName);
680
729
 
681
730
  // Auto-complete the current run
682
731
  let runCompleted = false;
@@ -705,7 +754,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
705
754
  }
706
755
 
707
756
  if (json) {
708
- jsonOutput("coordinator stop", {
757
+ jsonOutput(`${spec.commandName} stop`, {
709
758
  stopped: true,
710
759
  sessionId: session.id,
711
760
  watchdogStopped,
@@ -713,7 +762,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
713
762
  runCompleted,
714
763
  });
715
764
  } else {
716
- printSuccess("Coordinator stopped", session.id);
765
+ printSuccess(`${spec.displayName} stopped`, session.id);
717
766
  if (watchdogStopped) {
718
767
  printHint("Watchdog stopped");
719
768
  } else {
@@ -740,7 +789,8 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
740
789
  *
741
790
  * Checks session registry and tmux liveness to report actual state.
742
791
  */
743
- async function statusCoordinator(
792
+ async function statusPersistentAgent(
793
+ spec: PersistentAgentSpec,
744
794
  opts: { json: boolean },
745
795
  deps: CoordinatorDeps = {},
746
796
  ): Promise<void> {
@@ -764,20 +814,19 @@ async function statusCoordinator(
764
814
  const overstoryDir = join(projectRoot, ".overstory");
765
815
  const { store } = openSessionStore(overstoryDir);
766
816
  try {
767
- const session = store.getByName(COORDINATOR_NAME);
817
+ const session = store.getByName(spec.agentName);
768
818
  const watchdogRunning = await watchdog.isRunning();
769
819
  const monitorRunning = await monitor.isRunning();
770
820
 
771
- if (
772
- !session ||
773
- session.capability !== "coordinator" ||
774
- session.state === "completed" ||
775
- session.state === "zombie"
776
- ) {
821
+ if (!isActivePersistentAgentSession(session, spec)) {
777
822
  if (json) {
778
- jsonOutput("coordinator status", { running: false, watchdogRunning, monitorRunning });
823
+ jsonOutput(`${spec.commandName} status`, {
824
+ running: false,
825
+ watchdogRunning,
826
+ monitorRunning,
827
+ });
779
828
  } else {
780
- printHint("Coordinator is not running");
829
+ printHint(`${spec.displayName} is not running`);
781
830
  if (watchdogRunning) {
782
831
  printHint("Watchdog: running");
783
832
  }
@@ -794,8 +843,8 @@ async function statusCoordinator(
794
843
  // We already filtered out completed/zombie states above, so if tmux is dead
795
844
  // this session needs to be marked as zombie.
796
845
  if (!alive) {
797
- store.updateState(COORDINATOR_NAME, "zombie");
798
- store.updateLastActivity(COORDINATOR_NAME);
846
+ store.updateState(spec.agentName, "zombie");
847
+ store.updateLastActivity(spec.agentName);
799
848
  session.state = "zombie";
800
849
  }
801
850
 
@@ -812,10 +861,10 @@ async function statusCoordinator(
812
861
  };
813
862
 
814
863
  if (json) {
815
- jsonOutput("coordinator status", status);
864
+ jsonOutput(`${spec.commandName} status`, status);
816
865
  } else {
817
866
  const stateLabel = alive ? "running" : session.state;
818
- process.stdout.write(`Coordinator: ${stateLabel}\n`);
867
+ process.stdout.write(`${spec.displayName}: ${stateLabel}\n`);
819
868
  process.stdout.write(` Session: ${session.id}\n`);
820
869
  process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
821
870
  process.stdout.write(` PID: ${session.pid}\n`);
@@ -835,7 +884,8 @@ async function statusCoordinator(
835
884
  * Sends a mail message (from: operator, type: dispatch) and auto-nudges the
836
885
  * coordinator via tmux sendKeys. Replaces the two-step `ov mail send + ov nudge` pattern.
837
886
  */
838
- async function sendToCoordinator(
887
+ async function sendToPersistentAgent(
888
+ spec: PersistentAgentSpec,
839
889
  body: string,
840
890
  opts: { subject: string; json: boolean },
841
891
  deps: CoordinatorDeps = {},
@@ -859,26 +909,24 @@ async function sendToCoordinator(
859
909
  const overstoryDir = join(projectRoot, ".overstory");
860
910
  const { store } = openSessionStore(overstoryDir);
861
911
  try {
862
- const session = store.getByName(COORDINATOR_NAME);
912
+ const session = store.getByName(spec.agentName);
863
913
 
864
- if (
865
- !session ||
866
- session.capability !== "coordinator" ||
867
- session.state === "completed" ||
868
- session.state === "zombie"
869
- ) {
870
- throw new AgentError("No active coordinator session found", {
871
- agentName: COORDINATOR_NAME,
914
+ if (!isActivePersistentAgentSession(session, spec)) {
915
+ throw new AgentError(`No active ${spec.commandName} session found`, {
916
+ agentName: spec.agentName,
872
917
  });
873
918
  }
874
919
 
875
920
  const alive = await tmux.isSessionAlive(session.tmuxSession);
876
921
  if (!alive) {
877
- store.updateState(COORDINATOR_NAME, "zombie");
878
- store.updateLastActivity(COORDINATOR_NAME);
879
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
880
- agentName: COORDINATOR_NAME,
881
- });
922
+ store.updateState(spec.agentName, "zombie");
923
+ store.updateLastActivity(spec.agentName);
924
+ throw new AgentError(
925
+ `${spec.displayName} tmux session "${session.tmuxSession}" is not alive`,
926
+ {
927
+ agentName: spec.agentName,
928
+ },
929
+ );
882
930
  }
883
931
 
884
932
  // Send mail
@@ -889,7 +937,7 @@ async function sendToCoordinator(
889
937
  try {
890
938
  id = mailClient.send({
891
939
  from: "operator",
892
- to: COORDINATOR_NAME,
940
+ to: spec.agentName,
893
941
  subject,
894
942
  body,
895
943
  type: "dispatch",
@@ -903,16 +951,16 @@ async function sendToCoordinator(
903
951
  const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
904
952
  let nudged = false;
905
953
  try {
906
- const nudgeResult = await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
954
+ const nudgeResult = await nudge(projectRoot, spec.agentName, nudgeMessage, true);
907
955
  nudged = nudgeResult.delivered;
908
956
  } catch {
909
957
  // Nudge is fire-and-forget — silently ignore errors
910
958
  }
911
959
 
912
960
  if (json) {
913
- jsonOutput("coordinator send", { id, nudged });
961
+ jsonOutput(`${spec.commandName} send`, { id, nudged });
914
962
  } else {
915
- printSuccess("Sent to coordinator", id);
963
+ printSuccess(`Sent to ${spec.commandName}`, id);
916
964
  }
917
965
  } finally {
918
966
  store.close();
@@ -931,6 +979,15 @@ export async function askCoordinator(
931
979
  body: string,
932
980
  opts: { subject: string; timeout: number; json: boolean },
933
981
  deps: CoordinatorDeps = {},
982
+ ): Promise<void> {
983
+ await askPersistentAgent(COORDINATOR_SPEC, body, opts, deps);
984
+ }
985
+
986
+ export async function askPersistentAgent(
987
+ spec: PersistentAgentSpec,
988
+ body: string,
989
+ opts: { subject: string; timeout: number; json: boolean },
990
+ deps: CoordinatorDeps = {},
934
991
  ): Promise<void> {
935
992
  const tmux = deps._tmux ?? {
936
993
  createSession,
@@ -952,26 +1009,24 @@ export async function askCoordinator(
952
1009
  const overstoryDir = join(projectRoot, ".overstory");
953
1010
  const { store } = openSessionStore(overstoryDir);
954
1011
  try {
955
- const session = store.getByName(COORDINATOR_NAME);
1012
+ const session = store.getByName(spec.agentName);
956
1013
 
957
- if (
958
- !session ||
959
- session.capability !== "coordinator" ||
960
- session.state === "completed" ||
961
- session.state === "zombie"
962
- ) {
963
- throw new AgentError("No active coordinator session found", {
964
- agentName: COORDINATOR_NAME,
1014
+ if (!isActivePersistentAgentSession(session, spec)) {
1015
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1016
+ agentName: spec.agentName,
965
1017
  });
966
1018
  }
967
1019
 
968
1020
  const alive = await tmux.isSessionAlive(session.tmuxSession);
969
1021
  if (!alive) {
970
- store.updateState(COORDINATOR_NAME, "zombie");
971
- store.updateLastActivity(COORDINATOR_NAME);
972
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
973
- agentName: COORDINATOR_NAME,
974
- });
1022
+ store.updateState(spec.agentName, "zombie");
1023
+ store.updateLastActivity(spec.agentName);
1024
+ throw new AgentError(
1025
+ `${spec.displayName} tmux session "${session.tmuxSession}" is not alive`,
1026
+ {
1027
+ agentName: spec.agentName,
1028
+ },
1029
+ );
975
1030
  }
976
1031
 
977
1032
  // Generate correlation ID for tracking this request/response pair
@@ -985,7 +1040,7 @@ export async function askCoordinator(
985
1040
  try {
986
1041
  sentId = mailClient.send({
987
1042
  from: "operator",
988
- to: COORDINATOR_NAME,
1043
+ to: spec.agentName,
989
1044
  subject,
990
1045
  body,
991
1046
  type: "dispatch",
@@ -999,7 +1054,7 @@ export async function askCoordinator(
999
1054
  // Auto-nudge (fire-and-forget)
1000
1055
  const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
1001
1056
  try {
1002
- await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
1057
+ await nudge(projectRoot, spec.agentName, nudgeMessage, true);
1003
1058
  } catch {
1004
1059
  // Nudge is fire-and-forget — silently ignore errors
1005
1060
  }
@@ -1013,13 +1068,13 @@ export async function askCoordinator(
1013
1068
  let reply: import("../types.ts").MailMessage | undefined;
1014
1069
  try {
1015
1070
  const replies = pollStore.getByThread(sentId);
1016
- reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === "operator");
1071
+ reply = replies.find((m) => m.from === spec.agentName && m.to === "operator");
1017
1072
  } finally {
1018
1073
  pollStore.close();
1019
1074
  }
1020
1075
  if (reply) {
1021
1076
  if (json) {
1022
- jsonOutput("coordinator ask", {
1077
+ jsonOutput(`${spec.commandName} ask`, {
1023
1078
  correlationId,
1024
1079
  sentId,
1025
1080
  replyId: reply.id,
@@ -1035,8 +1090,8 @@ export async function askCoordinator(
1035
1090
  }
1036
1091
 
1037
1092
  throw new AgentError(
1038
- `Timed out after ${timeout}s waiting for coordinator reply (correlationId: ${correlationId})`,
1039
- { agentName: COORDINATOR_NAME },
1093
+ `Timed out after ${timeout}s waiting for ${spec.commandName} reply (correlationId: ${correlationId})`,
1094
+ { agentName: spec.agentName },
1040
1095
  );
1041
1096
  } finally {
1042
1097
  store.close();
@@ -1048,7 +1103,8 @@ export async function askCoordinator(
1048
1103
  *
1049
1104
  * Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
1050
1105
  */
1051
- async function outputCoordinator(
1106
+ async function outputPersistentAgent(
1107
+ spec: PersistentAgentSpec,
1052
1108
  opts: { follow: boolean; lines: number; interval: number; json: boolean },
1053
1109
  deps: CoordinatorDeps = {},
1054
1110
  ): Promise<void> {
@@ -1071,26 +1127,24 @@ async function outputCoordinator(
1071
1127
  const overstoryDir = join(projectRoot, ".overstory");
1072
1128
  const { store } = openSessionStore(overstoryDir);
1073
1129
  try {
1074
- const session = store.getByName(COORDINATOR_NAME);
1130
+ const session = store.getByName(spec.agentName);
1075
1131
 
1076
- if (
1077
- !session ||
1078
- session.capability !== "coordinator" ||
1079
- session.state === "completed" ||
1080
- session.state === "zombie"
1081
- ) {
1082
- throw new AgentError("No active coordinator session found", {
1083
- agentName: COORDINATOR_NAME,
1132
+ if (!isActivePersistentAgentSession(session, spec)) {
1133
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1134
+ agentName: spec.agentName,
1084
1135
  });
1085
1136
  }
1086
1137
 
1087
1138
  const alive = await tmux.isSessionAlive(session.tmuxSession);
1088
1139
  if (!alive) {
1089
- store.updateState(COORDINATOR_NAME, "zombie");
1090
- store.updateLastActivity(COORDINATOR_NAME);
1091
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
1092
- agentName: COORDINATOR_NAME,
1093
- });
1140
+ store.updateState(spec.agentName, "zombie");
1141
+ store.updateLastActivity(spec.agentName);
1142
+ throw new AgentError(
1143
+ `${spec.displayName} tmux session "${session.tmuxSession}" is not alive`,
1144
+ {
1145
+ agentName: spec.agentName,
1146
+ },
1147
+ );
1094
1148
  }
1095
1149
 
1096
1150
  const tmuxSession = session.tmuxSession;
@@ -1105,7 +1159,7 @@ async function outputCoordinator(
1105
1159
  while (running) {
1106
1160
  const content = await capturePane(tmuxSession, lines);
1107
1161
  if (json) {
1108
- jsonOutput("coordinator output", { content, lines });
1162
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1109
1163
  } else {
1110
1164
  process.stdout.write(content ?? "");
1111
1165
  }
@@ -1116,7 +1170,7 @@ async function outputCoordinator(
1116
1170
  } else {
1117
1171
  const content = await capturePane(tmuxSession, lines);
1118
1172
  if (json) {
1119
- jsonOutput("coordinator output", { content, lines });
1173
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1120
1174
  } else {
1121
1175
  process.stdout.write(content ?? "");
1122
1176
  }
@@ -1290,19 +1344,21 @@ export async function checkComplete(
1290
1344
  return result;
1291
1345
  }
1292
1346
 
1293
- /**
1294
- * Create the Commander command for `ov coordinator`.
1295
- */
1296
- export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1297
- const cmd = new Command("coordinator").description("Manage the persistent coordinator agent");
1347
+ export function createPersistentAgentCommand(
1348
+ spec: PersistentAgentSpec,
1349
+ deps: CoordinatorDeps = {},
1350
+ ): Command {
1351
+ const cmd = new Command(spec.commandName).description(
1352
+ `Manage the persistent ${spec.commandName} agent`,
1353
+ );
1298
1354
 
1299
1355
  cmd
1300
1356
  .command("start")
1301
- .description("Start the coordinator (spawns Claude Code at project root)")
1357
+ .description(`Start the ${spec.commandName} (spawns Claude Code at project root)`)
1302
1358
  .option("--attach", "Always attach to tmux session after start")
1303
1359
  .option("--no-attach", "Never attach to tmux session after start")
1304
- .option("--watchdog", "Auto-start watchdog daemon with coordinator")
1305
- .option("--monitor", "Auto-start Tier 2 monitor agent with coordinator")
1360
+ .option("--watchdog", `Auto-start watchdog daemon with ${spec.commandName}`)
1361
+ .option("--monitor", `Auto-start Tier 2 monitor agent with ${spec.commandName}`)
1306
1362
  .option("--profile <name>", "Canopy profile to apply to spawned agents")
1307
1363
  .option("--json", "Output as JSON")
1308
1364
  .action(
@@ -1315,7 +1371,8 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1315
1371
  }) => {
1316
1372
  // opts.attach = true if --attach, false if --no-attach, undefined if neither
1317
1373
  const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
1318
- await startCoordinator(
1374
+ await startPersistentAgent(
1375
+ spec,
1319
1376
  {
1320
1377
  json: opts.json ?? false,
1321
1378
  attach: shouldAttach,
@@ -1330,39 +1387,45 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1330
1387
 
1331
1388
  cmd
1332
1389
  .command("stop")
1333
- .description("Stop the coordinator (kills tmux session)")
1390
+ .description(`Stop the ${spec.commandName} (kills tmux session)`)
1334
1391
  .option("--json", "Output as JSON")
1335
1392
  .action(async (opts: { json?: boolean }) => {
1336
- await stopCoordinator({ json: opts.json ?? false }, deps);
1393
+ await stopPersistentAgent(spec, { json: opts.json ?? false }, deps);
1337
1394
  });
1338
1395
 
1339
1396
  cmd
1340
1397
  .command("status")
1341
- .description("Show coordinator state")
1398
+ .description(`Show ${spec.commandName} state`)
1342
1399
  .option("--json", "Output as JSON")
1343
1400
  .action(async (opts: { json?: boolean }) => {
1344
- await statusCoordinator({ json: opts.json ?? false }, deps);
1401
+ await statusPersistentAgent(spec, { json: opts.json ?? false }, deps);
1345
1402
  });
1346
1403
 
1347
1404
  cmd
1348
1405
  .command("send")
1349
- .description("Send a message to the coordinator (fire-and-forget)")
1406
+ .description(`Send a message to the ${spec.commandName} (fire-and-forget)`)
1350
1407
  .requiredOption("--body <text>", "Message body")
1351
1408
  .option("--subject <text>", "Message subject", "operator dispatch")
1352
1409
  .option("--json", "Output as JSON")
1353
1410
  .action(async (opts: { body: string; subject: string; json?: boolean }) => {
1354
- await sendToCoordinator(opts.body, { subject: opts.subject, json: opts.json ?? false }, deps);
1411
+ await sendToPersistentAgent(
1412
+ spec,
1413
+ opts.body,
1414
+ { subject: opts.subject, json: opts.json ?? false },
1415
+ deps,
1416
+ );
1355
1417
  });
1356
1418
 
1357
1419
  cmd
1358
1420
  .command("ask")
1359
- .description("Send a request to the coordinator and wait for a reply")
1421
+ .description(`Send a request to the ${spec.commandName} and wait for a reply`)
1360
1422
  .requiredOption("--body <text>", "Message body")
1361
1423
  .option("--subject <text>", "Message subject", "operator request")
1362
1424
  .option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
1363
1425
  .option("--json", "Output as JSON")
1364
1426
  .action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
1365
- await askCoordinator(
1427
+ await askPersistentAgent(
1428
+ spec,
1366
1429
  opts.body,
1367
1430
  {
1368
1431
  subject: opts.subject,
@@ -1375,14 +1438,15 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1375
1438
 
1376
1439
  cmd
1377
1440
  .command("output")
1378
- .description("Show recent coordinator output (tmux pane content)")
1441
+ .description(`Show recent ${spec.commandName} output (tmux pane content)`)
1379
1442
  .option("--follow, -f", "Continuously poll for new output")
1380
1443
  .option("--lines <n>", "Number of lines to capture", "50")
1381
1444
  .option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
1382
1445
  .option("--json", "Output as JSON")
1383
1446
  .action(
1384
1447
  async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
1385
- await outputCoordinator(
1448
+ await outputPersistentAgent(
1449
+ spec,
1386
1450
  {
1387
1451
  follow: opts.follow ?? false,
1388
1452
  lines: Number.parseInt(opts.lines ?? "50", 10),
@@ -1394,6 +1458,15 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1394
1458
  },
1395
1459
  );
1396
1460
 
1461
+ return cmd;
1462
+ }
1463
+
1464
+ /**
1465
+ * Create the Commander command for `ov coordinator`.
1466
+ */
1467
+ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1468
+ const cmd = createPersistentAgentCommand(COORDINATOR_SPEC, deps);
1469
+
1397
1470
  cmd
1398
1471
  .command("check-complete")
1399
1472
  .description("Evaluate exit triggers and report completion status")
@@ -1405,6 +1478,36 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1405
1478
  return cmd;
1406
1479
  }
1407
1480
 
1481
+ export async function persistentAgentCommand(
1482
+ args: string[],
1483
+ spec: PersistentAgentSpec,
1484
+ deps: CoordinatorDeps = {},
1485
+ ): Promise<void> {
1486
+ const cmd = createPersistentAgentCommand(spec, deps);
1487
+ cmd.exitOverride();
1488
+
1489
+ if (args.length === 0) {
1490
+ process.stdout.write(cmd.helpInformation());
1491
+ return;
1492
+ }
1493
+
1494
+ try {
1495
+ await cmd.parseAsync(args, { from: "user" });
1496
+ } catch (err: unknown) {
1497
+ if (err && typeof err === "object" && "code" in err) {
1498
+ const code = (err as { code: string }).code;
1499
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
1500
+ return;
1501
+ }
1502
+ if (code === "commander.unknownCommand") {
1503
+ const message = err instanceof Error ? err.message : String(err);
1504
+ throw new ValidationError(message, { field: "subcommand" });
1505
+ }
1506
+ }
1507
+ throw err;
1508
+ }
1509
+ }
1510
+
1408
1511
  /**
1409
1512
  * Entry point for `ov coordinator <subcommand>`.
1410
1513
  *