@os-eco/overstory-cli 0.9.1 → 0.9.2

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