@os-eco/overstory-cli 0.8.7 → 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 (98) hide show
  1. package/README.md +26 -8
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/agents/ov-co-creation.md +90 -0
  5. package/package.json +1 -1
  6. package/src/agents/hooks-deployer.test.ts +9 -1
  7. package/src/agents/hooks-deployer.ts +2 -1
  8. package/src/agents/overlay.test.ts +26 -0
  9. package/src/agents/overlay.ts +31 -4
  10. package/src/canopy/client.test.ts +107 -0
  11. package/src/canopy/client.ts +179 -0
  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 +304 -146
  19. package/src/commands/dashboard.ts +47 -10
  20. package/src/commands/discover.test.ts +288 -0
  21. package/src/commands/discover.ts +202 -0
  22. package/src/commands/doctor.ts +3 -1
  23. package/src/commands/ecosystem.test.ts +126 -1
  24. package/src/commands/ecosystem.ts +7 -53
  25. package/src/commands/feed.test.ts +117 -2
  26. package/src/commands/feed.ts +46 -30
  27. package/src/commands/group.test.ts +274 -155
  28. package/src/commands/group.ts +11 -5
  29. package/src/commands/init.test.ts +2 -1
  30. package/src/commands/init.ts +8 -0
  31. package/src/commands/log.test.ts +35 -0
  32. package/src/commands/log.ts +10 -6
  33. package/src/commands/logs.test.ts +423 -1
  34. package/src/commands/logs.ts +99 -104
  35. package/src/commands/orchestrator.ts +42 -0
  36. package/src/commands/prime.test.ts +177 -2
  37. package/src/commands/prime.ts +4 -2
  38. package/src/commands/sling.ts +23 -3
  39. package/src/commands/update.test.ts +1 -0
  40. package/src/commands/upgrade.test.ts +2 -0
  41. package/src/commands/upgrade.ts +1 -17
  42. package/src/commands/watch.test.ts +67 -1
  43. package/src/commands/watch.ts +13 -88
  44. package/src/config.test.ts +250 -0
  45. package/src/config.ts +43 -0
  46. package/src/doctor/agents.test.ts +72 -5
  47. package/src/doctor/agents.ts +10 -10
  48. package/src/doctor/consistency.test.ts +35 -0
  49. package/src/doctor/consistency.ts +7 -3
  50. package/src/doctor/dependencies.test.ts +58 -1
  51. package/src/doctor/dependencies.ts +4 -2
  52. package/src/doctor/providers.test.ts +41 -5
  53. package/src/doctor/types.ts +2 -1
  54. package/src/doctor/version.test.ts +106 -2
  55. package/src/doctor/version.ts +4 -2
  56. package/src/doctor/watchdog.test.ts +167 -0
  57. package/src/doctor/watchdog.ts +158 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +4 -2
  59. package/src/errors.test.ts +350 -0
  60. package/src/events/tailer.test.ts +25 -0
  61. package/src/events/tailer.ts +8 -1
  62. package/src/index.ts +9 -1
  63. package/src/mail/store.test.ts +110 -0
  64. package/src/mail/store.ts +2 -1
  65. package/src/runtimes/aider.test.ts +124 -0
  66. package/src/runtimes/aider.ts +147 -0
  67. package/src/runtimes/amp.test.ts +164 -0
  68. package/src/runtimes/amp.ts +154 -0
  69. package/src/runtimes/claude.test.ts +4 -2
  70. package/src/runtimes/goose.test.ts +133 -0
  71. package/src/runtimes/goose.ts +157 -0
  72. package/src/runtimes/pi-guards.ts +2 -1
  73. package/src/runtimes/pi.test.ts +9 -9
  74. package/src/runtimes/pi.ts +6 -7
  75. package/src/runtimes/registry.test.ts +1 -1
  76. package/src/runtimes/registry.ts +13 -4
  77. package/src/runtimes/sapling.ts +2 -1
  78. package/src/runtimes/types.ts +2 -2
  79. package/src/schema-consistency.test.ts +1 -0
  80. package/src/sessions/store.ts +25 -4
  81. package/src/types.ts +65 -1
  82. package/src/utils/bin.test.ts +10 -0
  83. package/src/utils/bin.ts +37 -0
  84. package/src/utils/fs.test.ts +119 -0
  85. package/src/utils/fs.ts +62 -0
  86. package/src/utils/pid.test.ts +68 -0
  87. package/src/utils/pid.ts +45 -0
  88. package/src/utils/time.test.ts +43 -0
  89. package/src/utils/time.ts +37 -0
  90. package/src/utils/version.test.ts +33 -0
  91. package/src/utils/version.ts +70 -0
  92. package/src/watchdog/daemon.test.ts +255 -1
  93. package/src/watchdog/daemon.ts +87 -9
  94. package/src/watchdog/health.test.ts +15 -1
  95. package/src/watchdog/health.ts +1 -1
  96. package/src/watchdog/triage.test.ts +49 -9
  97. package/src/watchdog/triage.ts +21 -5
  98. package/templates/overlay.md.tmpl +2 -0
@@ -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
 
@@ -56,8 +74,8 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
56
74
  * Build the tmux session name for the coordinator.
57
75
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
58
76
  */
59
- function coordinatorTmuxSession(projectName: string): string {
60
- return `overstory-${projectName}-${COORDINATOR_NAME}`;
77
+ function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
78
+ return `overstory-${projectName}-${name}`;
61
79
  }
62
80
 
63
81
  /** Dependency injection for testing. Uses real implementations when omitted. */
@@ -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(" — ");
@@ -290,8 +308,37 @@ export function resolveAttach(args: string[], isTTY: boolean): boolean {
290
308
  return isTTY;
291
309
  }
292
310
 
293
- async function startCoordinator(
294
- opts: { json: boolean; attach: boolean; watchdog: boolean; monitor: boolean },
311
+ /**
312
+ * Options for the reusable coordinator session startup core.
313
+ * Used by startCoordinatorSession() and consumed by commands like ov discover.
314
+ */
315
+ export interface CoordinatorSessionOptions {
316
+ json: boolean;
317
+ attach: boolean;
318
+ watchdog: boolean;
319
+ monitor: boolean;
320
+ profile?: string;
321
+ /** Override coordinator name (default: "coordinator"). */
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;
331
+ /** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
332
+ beaconBuilder?: (trackerCli: string) => string;
333
+ }
334
+
335
+ /**
336
+ * Core coordinator session startup logic. Reusable by commands that need to
337
+ * start a coordinator-like session with a custom name or beacon
338
+ * (e.g., ov discover uses coordinatorName: "discover-coordinator").
339
+ */
340
+ export async function startCoordinatorSession(
341
+ opts: CoordinatorSessionOptions,
295
342
  deps: CoordinatorDeps = {},
296
343
  ): Promise<void> {
297
344
  const tmux = deps._tmux ?? {
@@ -304,7 +351,25 @@ async function startCoordinator(
304
351
  ensureTmuxAvailable,
305
352
  };
306
353
 
307
- const { json, attach: shouldAttach, watchdog: watchdogFlag, monitor: monitorFlag } = opts;
354
+ const {
355
+ json,
356
+ attach: shouldAttach,
357
+ watchdog: watchdogFlag,
358
+ monitor: monitorFlag,
359
+ profile: profileFlag,
360
+ coordinatorName: coordinatorNameOpt,
361
+ agentName: agentNameOpt,
362
+ capability: capabilityOpt,
363
+ agentDefFile: agentDefFileOpt,
364
+ displayName: displayNameOpt,
365
+ beaconBuilder: beaconBuilderOpt,
366
+ } = opts;
367
+
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;
372
+ const beaconBuilder = beaconBuilderOpt ?? buildCoordinatorBeacon;
308
373
 
309
374
  if (isRunningAsRoot()) {
310
375
  throw new AgentError(
@@ -317,17 +382,17 @@ async function startCoordinator(
317
382
  const projectRoot = config.project.root;
318
383
  const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
319
384
  const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
320
- const tmuxSession = coordinatorTmuxSession(config.project.name);
385
+ const tmuxSession = coordinatorTmuxSession(config.project.name, coordinatorName);
321
386
 
322
- // Check for existing coordinator
387
+ // Check for existing coordinator session with the same name
323
388
  const overstoryDir = join(projectRoot, ".overstory");
324
389
  const { store } = openSessionStore(overstoryDir);
325
390
  try {
326
- const existing = store.getByName(COORDINATOR_NAME);
391
+ const existing = store.getByName(coordinatorName);
327
392
 
328
393
  if (
329
394
  existing &&
330
- existing.capability === "coordinator" &&
395
+ existing.capability === capability &&
331
396
  existing.state !== "completed" &&
332
397
  existing.state !== "zombie"
333
398
  ) {
@@ -340,19 +405,19 @@ async function startCoordinator(
340
405
  // Zombie: tmux pane exists but agent process has exited.
341
406
  // Kill the empty session and reclaim the slot.
342
407
  await tmux.killSession(existing.tmuxSession);
343
- store.updateState(COORDINATOR_NAME, "completed");
408
+ store.updateState(coordinatorName, "completed");
344
409
  } else {
345
410
  // Either the process is genuinely running (pid alive), or pid is null
346
411
  // (e.g. sessions migrated from an older schema). In both cases we
347
412
  // cannot prove the session is a zombie, so treat it as active.
348
413
  throw new AgentError(
349
- `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
350
- { agentName: COORDINATOR_NAME },
414
+ `${displayName} is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
415
+ { agentName: coordinatorName },
351
416
  );
352
417
  }
353
418
  } else {
354
419
  // Session is dead or tmux server is not running -- clean up stale DB entry.
355
- store.updateState(COORDINATOR_NAME, "completed");
420
+ store.updateState(coordinatorName, "completed");
356
421
  }
357
422
  }
358
423
 
@@ -362,8 +427,8 @@ async function startCoordinator(
362
427
  join(projectRoot, config.agents.baseDir),
363
428
  );
364
429
  const manifest = await manifestLoader.load();
365
- const resolvedModel = resolveModel(config, manifest, "coordinator", "opus");
366
- const runtime = getRuntime(undefined, config, "coordinator");
430
+ const resolvedModel = resolveModel(config, manifest, capability, "opus");
431
+ const runtime = getRuntime(undefined, config, capability);
367
432
 
368
433
  // Deploy hooks to the project root so the coordinator gets event logging,
369
434
  // mail check --inject, and activity tracking via the standard hook pipeline.
@@ -372,19 +437,19 @@ async function startCoordinator(
372
437
  // the coordinator's tmux session), so the user's own Claude Code session
373
438
  // at the project root is unaffected.
374
439
  await runtime.deployConfig(projectRoot, undefined, {
375
- agentName: COORDINATOR_NAME,
376
- capability: "coordinator",
440
+ agentName: coordinatorName,
441
+ capability,
377
442
  worktreePath: projectRoot,
378
443
  });
379
444
 
380
445
  // Create coordinator identity if first run
381
446
  const identityBaseDir = join(projectRoot, ".overstory", "agents");
382
447
  await mkdir(identityBaseDir, { recursive: true });
383
- const existingIdentity = await loadIdentity(identityBaseDir, COORDINATOR_NAME);
448
+ const existingIdentity = await loadIdentity(identityBaseDir, coordinatorName);
384
449
  if (!existingIdentity) {
385
450
  await createIdentity(identityBaseDir, {
386
- name: COORDINATOR_NAME,
387
- capability: "coordinator",
451
+ name: coordinatorName,
452
+ capability,
388
453
  created: new Date().toISOString(),
389
454
  sessionsCompleted: 0,
390
455
  expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
@@ -403,10 +468,10 @@ async function startCoordinator(
403
468
  // Pass the file path (not content) so the shell inside the tmux pane reads
404
469
  // it via $(cat ...) — avoids tmux IPC "command too long" errors with large
405
470
  // agent definitions (overstory#45).
406
- const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "coordinator.md");
407
- const agentDefFile = Bun.file(agentDefPath);
471
+ const agentDefPath = join(projectRoot, ".overstory", "agent-defs", agentDefFile);
472
+ const agentDefHandle = Bun.file(agentDefPath);
408
473
  let appendSystemPromptFile: string | undefined;
409
- if (await agentDefFile.exists()) {
474
+ if (await agentDefHandle.exists()) {
410
475
  appendSystemPromptFile = agentDefPath;
411
476
  }
412
477
  const spawnCmd = runtime.buildSpawnCommand({
@@ -416,17 +481,19 @@ async function startCoordinator(
416
481
  appendSystemPromptFile,
417
482
  env: {
418
483
  ...runtime.buildEnv(resolvedModel),
419
- OVERSTORY_AGENT_NAME: COORDINATOR_NAME,
484
+ OVERSTORY_AGENT_NAME: coordinatorName,
485
+ ...(profileFlag ? { OVERSTORY_PROFILE: profileFlag } : {}),
420
486
  },
421
487
  });
422
488
  const pid = await tmux.createSession(tmuxSession, projectRoot, spawnCmd, {
423
489
  ...runtime.buildEnv(resolvedModel),
424
- OVERSTORY_AGENT_NAME: COORDINATOR_NAME,
490
+ OVERSTORY_AGENT_NAME: coordinatorName,
491
+ ...(profileFlag ? { OVERSTORY_PROFILE: profileFlag } : {}),
425
492
  });
426
493
 
427
494
  // Create a run for this coordinator session BEFORE recording the session,
428
495
  // so the session can reference the run ID from the start.
429
- const sessionId = `session-${Date.now()}-${COORDINATOR_NAME}`;
496
+ const sessionId = `session-${Date.now()}-${coordinatorName}`;
430
497
  const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
431
498
  const runStore = createRunStore(join(overstoryDir, "sessions.db"));
432
499
  try {
@@ -434,7 +501,7 @@ async function startCoordinator(
434
501
  id: runId,
435
502
  startedAt: new Date().toISOString(),
436
503
  coordinatorSessionId: sessionId,
437
- coordinatorName: COORDINATOR_NAME,
504
+ coordinatorName,
438
505
  status: "active",
439
506
  });
440
507
  } finally {
@@ -449,8 +516,8 @@ async function startCoordinator(
449
516
  // leaving the coordinator stuck in "booting" (overstory-036f).
450
517
  const session: AgentSession = {
451
518
  id: sessionId,
452
- agentName: COORDINATOR_NAME,
453
- capability: "coordinator",
519
+ agentName: coordinatorName,
520
+ capability,
454
521
  worktreePath: projectRoot, // Coordinator uses project root, not a worktree
455
522
  branchName: config.project.canonicalBranch, // Operates on canonical branch
456
523
  taskId: "", // No specific task assignment
@@ -484,29 +551,29 @@ async function startCoordinator(
484
551
  const alive = await tmux.isSessionAlive(tmuxSession);
485
552
  if (!alive) {
486
553
  // Clean up the stale session record
487
- store.updateState(COORDINATOR_NAME, "completed");
554
+ store.updateState(coordinatorName, "completed");
488
555
  const sessionState = await tmux.checkSessionState(tmuxSession);
489
556
  const detail =
490
557
  sessionState === "no_server"
491
558
  ? "The tmux server is no longer running. It may have crashed or been killed externally."
492
559
  : "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
493
560
  throw new AgentError(
494
- `Coordinator tmux session "${tmuxSession}" died during startup. ${detail}`,
495
- { agentName: COORDINATOR_NAME },
561
+ `${displayName} tmux session "${tmuxSession}" died during startup. ${detail}`,
562
+ { agentName: coordinatorName },
496
563
  );
497
564
  }
498
565
  await tmux.killSession(tmuxSession);
499
- store.updateState(COORDINATOR_NAME, "completed");
566
+ store.updateState(coordinatorName, "completed");
500
567
  throw new AgentError(
501
- `Coordinator tmux session "${tmuxSession}" did not become ready during startup. Claude Code may still be waiting on an interactive dialog or initializing too slowly.`,
502
- { agentName: COORDINATOR_NAME },
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.`,
569
+ { agentName: coordinatorName },
503
570
  );
504
571
  }
505
572
  await Bun.sleep(1_000);
506
573
 
507
574
  const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
508
575
  const trackerCli = trackerCliName(resolvedBackend);
509
- const beacon = buildCoordinatorBeacon(trackerCli);
576
+ const beacon = beaconBuilder(trackerCli);
510
577
  await tmux.sendKeys(tmuxSession, beacon);
511
578
 
512
579
  // Follow-up Enters with increasing delays to ensure submission
@@ -544,8 +611,8 @@ async function startCoordinator(
544
611
  }
545
612
 
546
613
  const output = {
547
- agentName: COORDINATOR_NAME,
548
- capability: "coordinator",
614
+ agentName: coordinatorName,
615
+ capability,
549
616
  tmuxSession,
550
617
  projectRoot,
551
618
  pid,
@@ -554,9 +621,9 @@ async function startCoordinator(
554
621
  };
555
622
 
556
623
  if (json) {
557
- jsonOutput("coordinator start", output);
624
+ jsonOutput(`${capability} start`, output);
558
625
  } else {
559
- printSuccess("Coordinator started");
626
+ printSuccess(`${displayName} started`);
560
627
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
561
628
  process.stdout.write(` Root: ${projectRoot}\n`);
562
629
  process.stdout.write(` PID: ${pid}\n`);
@@ -572,6 +639,36 @@ async function startCoordinator(
572
639
  }
573
640
  }
574
641
 
642
+ async function startPersistentAgent(
643
+ spec: PersistentAgentSpec,
644
+ opts: { json: boolean; attach: boolean; watchdog: boolean; monitor: boolean; profile?: string },
645
+ deps: CoordinatorDeps = {},
646
+ ): Promise<void> {
647
+ await startCoordinatorSession(
648
+ {
649
+ ...opts,
650
+ agentName: spec.agentName,
651
+ capability: spec.capability,
652
+ agentDefFile: spec.agentDefFile,
653
+ displayName: spec.displayName,
654
+ beaconBuilder: spec.beaconBuilder,
655
+ },
656
+ deps,
657
+ );
658
+ }
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
+
575
672
  /**
576
673
  * Stop the coordinator agent.
577
674
  *
@@ -580,7 +677,11 @@ async function startCoordinator(
580
677
  * 3. Mark session as completed in SessionStore
581
678
  * 4. Auto-complete the active run (if current-run.txt exists)
582
679
  */
583
- 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> {
584
685
  const tmux = deps._tmux ?? {
585
686
  createSession,
586
687
  isSessionAlive,
@@ -601,16 +702,11 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
601
702
  const overstoryDir = join(projectRoot, ".overstory");
602
703
  const { store } = openSessionStore(overstoryDir);
603
704
  try {
604
- const session = store.getByName(COORDINATOR_NAME);
705
+ const session = store.getByName(spec.agentName);
605
706
 
606
- if (
607
- !session ||
608
- session.capability !== "coordinator" ||
609
- session.state === "completed" ||
610
- session.state === "zombie"
611
- ) {
612
- throw new AgentError("No active coordinator session found", {
613
- agentName: COORDINATOR_NAME,
707
+ if (!isActivePersistentAgentSession(session, spec)) {
708
+ throw new AgentError(`No active ${spec.commandName} session found`, {
709
+ agentName: spec.agentName,
614
710
  });
615
711
  }
616
712
 
@@ -627,8 +723,8 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
627
723
  const monitorStopped = await monitor.stop();
628
724
 
629
725
  // Update session state
630
- store.updateState(COORDINATOR_NAME, "completed");
631
- store.updateLastActivity(COORDINATOR_NAME);
726
+ store.updateState(spec.agentName, "completed");
727
+ store.updateLastActivity(spec.agentName);
632
728
 
633
729
  // Auto-complete the current run
634
730
  let runCompleted = false;
@@ -657,7 +753,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
657
753
  }
658
754
 
659
755
  if (json) {
660
- jsonOutput("coordinator stop", {
756
+ jsonOutput(`${spec.commandName} stop`, {
661
757
  stopped: true,
662
758
  sessionId: session.id,
663
759
  watchdogStopped,
@@ -665,7 +761,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
665
761
  runCompleted,
666
762
  });
667
763
  } else {
668
- printSuccess("Coordinator stopped", session.id);
764
+ printSuccess(`${spec.displayName} stopped`, session.id);
669
765
  if (watchdogStopped) {
670
766
  printHint("Watchdog stopped");
671
767
  } else {
@@ -692,7 +788,8 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
692
788
  *
693
789
  * Checks session registry and tmux liveness to report actual state.
694
790
  */
695
- async function statusCoordinator(
791
+ async function statusPersistentAgent(
792
+ spec: PersistentAgentSpec,
696
793
  opts: { json: boolean },
697
794
  deps: CoordinatorDeps = {},
698
795
  ): Promise<void> {
@@ -716,20 +813,19 @@ async function statusCoordinator(
716
813
  const overstoryDir = join(projectRoot, ".overstory");
717
814
  const { store } = openSessionStore(overstoryDir);
718
815
  try {
719
- const session = store.getByName(COORDINATOR_NAME);
816
+ const session = store.getByName(spec.agentName);
720
817
  const watchdogRunning = await watchdog.isRunning();
721
818
  const monitorRunning = await monitor.isRunning();
722
819
 
723
- if (
724
- !session ||
725
- session.capability !== "coordinator" ||
726
- session.state === "completed" ||
727
- session.state === "zombie"
728
- ) {
820
+ if (!isActivePersistentAgentSession(session, spec)) {
729
821
  if (json) {
730
- jsonOutput("coordinator status", { running: false, watchdogRunning, monitorRunning });
822
+ jsonOutput(`${spec.commandName} status`, {
823
+ running: false,
824
+ watchdogRunning,
825
+ monitorRunning,
826
+ });
731
827
  } else {
732
- printHint("Coordinator is not running");
828
+ printHint(`${spec.displayName} is not running`);
733
829
  if (watchdogRunning) {
734
830
  printHint("Watchdog: running");
735
831
  }
@@ -746,8 +842,8 @@ async function statusCoordinator(
746
842
  // We already filtered out completed/zombie states above, so if tmux is dead
747
843
  // this session needs to be marked as zombie.
748
844
  if (!alive) {
749
- store.updateState(COORDINATOR_NAME, "zombie");
750
- store.updateLastActivity(COORDINATOR_NAME);
845
+ store.updateState(spec.agentName, "zombie");
846
+ store.updateLastActivity(spec.agentName);
751
847
  session.state = "zombie";
752
848
  }
753
849
 
@@ -764,10 +860,10 @@ async function statusCoordinator(
764
860
  };
765
861
 
766
862
  if (json) {
767
- jsonOutput("coordinator status", status);
863
+ jsonOutput(`${spec.commandName} status`, status);
768
864
  } else {
769
865
  const stateLabel = alive ? "running" : session.state;
770
- process.stdout.write(`Coordinator: ${stateLabel}\n`);
866
+ process.stdout.write(`${spec.displayName}: ${stateLabel}\n`);
771
867
  process.stdout.write(` Session: ${session.id}\n`);
772
868
  process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
773
869
  process.stdout.write(` PID: ${session.pid}\n`);
@@ -787,7 +883,8 @@ async function statusCoordinator(
787
883
  * Sends a mail message (from: operator, type: dispatch) and auto-nudges the
788
884
  * coordinator via tmux sendKeys. Replaces the two-step `ov mail send + ov nudge` pattern.
789
885
  */
790
- async function sendToCoordinator(
886
+ async function sendToPersistentAgent(
887
+ spec: PersistentAgentSpec,
791
888
  body: string,
792
889
  opts: { subject: string; json: boolean },
793
890
  deps: CoordinatorDeps = {},
@@ -811,26 +908,24 @@ async function sendToCoordinator(
811
908
  const overstoryDir = join(projectRoot, ".overstory");
812
909
  const { store } = openSessionStore(overstoryDir);
813
910
  try {
814
- const session = store.getByName(COORDINATOR_NAME);
911
+ const session = store.getByName(spec.agentName);
815
912
 
816
- if (
817
- !session ||
818
- session.capability !== "coordinator" ||
819
- session.state === "completed" ||
820
- session.state === "zombie"
821
- ) {
822
- throw new AgentError("No active coordinator session found", {
823
- agentName: COORDINATOR_NAME,
913
+ if (!isActivePersistentAgentSession(session, spec)) {
914
+ throw new AgentError(`No active ${spec.commandName} session found`, {
915
+ agentName: spec.agentName,
824
916
  });
825
917
  }
826
918
 
827
919
  const alive = await tmux.isSessionAlive(session.tmuxSession);
828
920
  if (!alive) {
829
- store.updateState(COORDINATOR_NAME, "zombie");
830
- store.updateLastActivity(COORDINATOR_NAME);
831
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
832
- agentName: COORDINATOR_NAME,
833
- });
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
+ );
834
929
  }
835
930
 
836
931
  // Send mail
@@ -841,7 +936,7 @@ async function sendToCoordinator(
841
936
  try {
842
937
  id = mailClient.send({
843
938
  from: "operator",
844
- to: COORDINATOR_NAME,
939
+ to: spec.agentName,
845
940
  subject,
846
941
  body,
847
942
  type: "dispatch",
@@ -855,16 +950,16 @@ async function sendToCoordinator(
855
950
  const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
856
951
  let nudged = false;
857
952
  try {
858
- const nudgeResult = await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
953
+ const nudgeResult = await nudge(projectRoot, spec.agentName, nudgeMessage, true);
859
954
  nudged = nudgeResult.delivered;
860
955
  } catch {
861
956
  // Nudge is fire-and-forget — silently ignore errors
862
957
  }
863
958
 
864
959
  if (json) {
865
- jsonOutput("coordinator send", { id, nudged });
960
+ jsonOutput(`${spec.commandName} send`, { id, nudged });
866
961
  } else {
867
- printSuccess("Sent to coordinator", id);
962
+ printSuccess(`Sent to ${spec.commandName}`, id);
868
963
  }
869
964
  } finally {
870
965
  store.close();
@@ -883,6 +978,15 @@ export async function askCoordinator(
883
978
  body: string,
884
979
  opts: { subject: string; timeout: number; json: boolean },
885
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 = {},
886
990
  ): Promise<void> {
887
991
  const tmux = deps._tmux ?? {
888
992
  createSession,
@@ -904,26 +1008,24 @@ export async function askCoordinator(
904
1008
  const overstoryDir = join(projectRoot, ".overstory");
905
1009
  const { store } = openSessionStore(overstoryDir);
906
1010
  try {
907
- const session = store.getByName(COORDINATOR_NAME);
1011
+ const session = store.getByName(spec.agentName);
908
1012
 
909
- if (
910
- !session ||
911
- session.capability !== "coordinator" ||
912
- session.state === "completed" ||
913
- session.state === "zombie"
914
- ) {
915
- throw new AgentError("No active coordinator session found", {
916
- agentName: COORDINATOR_NAME,
1013
+ if (!isActivePersistentAgentSession(session, spec)) {
1014
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1015
+ agentName: spec.agentName,
917
1016
  });
918
1017
  }
919
1018
 
920
1019
  const alive = await tmux.isSessionAlive(session.tmuxSession);
921
1020
  if (!alive) {
922
- store.updateState(COORDINATOR_NAME, "zombie");
923
- store.updateLastActivity(COORDINATOR_NAME);
924
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
925
- agentName: COORDINATOR_NAME,
926
- });
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
+ );
927
1029
  }
928
1030
 
929
1031
  // Generate correlation ID for tracking this request/response pair
@@ -937,7 +1039,7 @@ export async function askCoordinator(
937
1039
  try {
938
1040
  sentId = mailClient.send({
939
1041
  from: "operator",
940
- to: COORDINATOR_NAME,
1042
+ to: spec.agentName,
941
1043
  subject,
942
1044
  body,
943
1045
  type: "dispatch",
@@ -951,7 +1053,7 @@ export async function askCoordinator(
951
1053
  // Auto-nudge (fire-and-forget)
952
1054
  const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
953
1055
  try {
954
- await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
1056
+ await nudge(projectRoot, spec.agentName, nudgeMessage, true);
955
1057
  } catch {
956
1058
  // Nudge is fire-and-forget — silently ignore errors
957
1059
  }
@@ -965,13 +1067,13 @@ export async function askCoordinator(
965
1067
  let reply: import("../types.ts").MailMessage | undefined;
966
1068
  try {
967
1069
  const replies = pollStore.getByThread(sentId);
968
- reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === "operator");
1070
+ reply = replies.find((m) => m.from === spec.agentName && m.to === "operator");
969
1071
  } finally {
970
1072
  pollStore.close();
971
1073
  }
972
1074
  if (reply) {
973
1075
  if (json) {
974
- jsonOutput("coordinator ask", {
1076
+ jsonOutput(`${spec.commandName} ask`, {
975
1077
  correlationId,
976
1078
  sentId,
977
1079
  replyId: reply.id,
@@ -987,8 +1089,8 @@ export async function askCoordinator(
987
1089
  }
988
1090
 
989
1091
  throw new AgentError(
990
- `Timed out after ${timeout}s waiting for coordinator reply (correlationId: ${correlationId})`,
991
- { agentName: COORDINATOR_NAME },
1092
+ `Timed out after ${timeout}s waiting for ${spec.commandName} reply (correlationId: ${correlationId})`,
1093
+ { agentName: spec.agentName },
992
1094
  );
993
1095
  } finally {
994
1096
  store.close();
@@ -1000,7 +1102,8 @@ export async function askCoordinator(
1000
1102
  *
1001
1103
  * Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
1002
1104
  */
1003
- async function outputCoordinator(
1105
+ async function outputPersistentAgent(
1106
+ spec: PersistentAgentSpec,
1004
1107
  opts: { follow: boolean; lines: number; interval: number; json: boolean },
1005
1108
  deps: CoordinatorDeps = {},
1006
1109
  ): Promise<void> {
@@ -1023,26 +1126,24 @@ async function outputCoordinator(
1023
1126
  const overstoryDir = join(projectRoot, ".overstory");
1024
1127
  const { store } = openSessionStore(overstoryDir);
1025
1128
  try {
1026
- const session = store.getByName(COORDINATOR_NAME);
1129
+ const session = store.getByName(spec.agentName);
1027
1130
 
1028
- if (
1029
- !session ||
1030
- session.capability !== "coordinator" ||
1031
- session.state === "completed" ||
1032
- session.state === "zombie"
1033
- ) {
1034
- throw new AgentError("No active coordinator session found", {
1035
- agentName: COORDINATOR_NAME,
1131
+ if (!isActivePersistentAgentSession(session, spec)) {
1132
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1133
+ agentName: spec.agentName,
1036
1134
  });
1037
1135
  }
1038
1136
 
1039
1137
  const alive = await tmux.isSessionAlive(session.tmuxSession);
1040
1138
  if (!alive) {
1041
- store.updateState(COORDINATOR_NAME, "zombie");
1042
- store.updateLastActivity(COORDINATOR_NAME);
1043
- throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
1044
- agentName: COORDINATOR_NAME,
1045
- });
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
+ );
1046
1147
  }
1047
1148
 
1048
1149
  const tmuxSession = session.tmuxSession;
@@ -1057,7 +1158,7 @@ async function outputCoordinator(
1057
1158
  while (running) {
1058
1159
  const content = await capturePane(tmuxSession, lines);
1059
1160
  if (json) {
1060
- jsonOutput("coordinator output", { content, lines });
1161
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1061
1162
  } else {
1062
1163
  process.stdout.write(content ?? "");
1063
1164
  }
@@ -1068,7 +1169,7 @@ async function outputCoordinator(
1068
1169
  } else {
1069
1170
  const content = await capturePane(tmuxSession, lines);
1070
1171
  if (json) {
1071
- jsonOutput("coordinator output", { content, lines });
1172
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1072
1173
  } else {
1073
1174
  process.stdout.write(content ?? "");
1074
1175
  }
@@ -1242,30 +1343,41 @@ export async function checkComplete(
1242
1343
  return result;
1243
1344
  }
1244
1345
 
1245
- /**
1246
- * Create the Commander command for `ov coordinator`.
1247
- */
1248
- export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1249
- 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
+ );
1250
1353
 
1251
1354
  cmd
1252
1355
  .command("start")
1253
- .description("Start the coordinator (spawns Claude Code at project root)")
1356
+ .description(`Start the ${spec.commandName} (spawns Claude Code at project root)`)
1254
1357
  .option("--attach", "Always attach to tmux session after start")
1255
1358
  .option("--no-attach", "Never attach to tmux session after start")
1256
- .option("--watchdog", "Auto-start watchdog daemon with coordinator")
1257
- .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}`)
1361
+ .option("--profile <name>", "Canopy profile to apply to spawned agents")
1258
1362
  .option("--json", "Output as JSON")
1259
1363
  .action(
1260
- async (opts: { attach?: boolean; watchdog?: boolean; monitor?: boolean; json?: boolean }) => {
1364
+ async (opts: {
1365
+ attach?: boolean;
1366
+ watchdog?: boolean;
1367
+ monitor?: boolean;
1368
+ json?: boolean;
1369
+ profile?: string;
1370
+ }) => {
1261
1371
  // opts.attach = true if --attach, false if --no-attach, undefined if neither
1262
1372
  const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
1263
- await startCoordinator(
1373
+ await startPersistentAgent(
1374
+ spec,
1264
1375
  {
1265
1376
  json: opts.json ?? false,
1266
1377
  attach: shouldAttach,
1267
1378
  watchdog: opts.watchdog ?? false,
1268
1379
  monitor: opts.monitor ?? false,
1380
+ profile: opts.profile,
1269
1381
  },
1270
1382
  deps,
1271
1383
  );
@@ -1274,39 +1386,45 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1274
1386
 
1275
1387
  cmd
1276
1388
  .command("stop")
1277
- .description("Stop the coordinator (kills tmux session)")
1389
+ .description(`Stop the ${spec.commandName} (kills tmux session)`)
1278
1390
  .option("--json", "Output as JSON")
1279
1391
  .action(async (opts: { json?: boolean }) => {
1280
- await stopCoordinator({ json: opts.json ?? false }, deps);
1392
+ await stopPersistentAgent(spec, { json: opts.json ?? false }, deps);
1281
1393
  });
1282
1394
 
1283
1395
  cmd
1284
1396
  .command("status")
1285
- .description("Show coordinator state")
1397
+ .description(`Show ${spec.commandName} state`)
1286
1398
  .option("--json", "Output as JSON")
1287
1399
  .action(async (opts: { json?: boolean }) => {
1288
- await statusCoordinator({ json: opts.json ?? false }, deps);
1400
+ await statusPersistentAgent(spec, { json: opts.json ?? false }, deps);
1289
1401
  });
1290
1402
 
1291
1403
  cmd
1292
1404
  .command("send")
1293
- .description("Send a message to the coordinator (fire-and-forget)")
1405
+ .description(`Send a message to the ${spec.commandName} (fire-and-forget)`)
1294
1406
  .requiredOption("--body <text>", "Message body")
1295
1407
  .option("--subject <text>", "Message subject", "operator dispatch")
1296
1408
  .option("--json", "Output as JSON")
1297
1409
  .action(async (opts: { body: string; subject: string; json?: boolean }) => {
1298
- 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
+ );
1299
1416
  });
1300
1417
 
1301
1418
  cmd
1302
1419
  .command("ask")
1303
- .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`)
1304
1421
  .requiredOption("--body <text>", "Message body")
1305
1422
  .option("--subject <text>", "Message subject", "operator request")
1306
1423
  .option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
1307
1424
  .option("--json", "Output as JSON")
1308
1425
  .action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
1309
- await askCoordinator(
1426
+ await askPersistentAgent(
1427
+ spec,
1310
1428
  opts.body,
1311
1429
  {
1312
1430
  subject: opts.subject,
@@ -1319,14 +1437,15 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1319
1437
 
1320
1438
  cmd
1321
1439
  .command("output")
1322
- .description("Show recent coordinator output (tmux pane content)")
1440
+ .description(`Show recent ${spec.commandName} output (tmux pane content)`)
1323
1441
  .option("--follow, -f", "Continuously poll for new output")
1324
1442
  .option("--lines <n>", "Number of lines to capture", "50")
1325
1443
  .option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
1326
1444
  .option("--json", "Output as JSON")
1327
1445
  .action(
1328
1446
  async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
1329
- await outputCoordinator(
1447
+ await outputPersistentAgent(
1448
+ spec,
1330
1449
  {
1331
1450
  follow: opts.follow ?? false,
1332
1451
  lines: Number.parseInt(opts.lines ?? "50", 10),
@@ -1338,6 +1457,15 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1338
1457
  },
1339
1458
  );
1340
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
+
1341
1469
  cmd
1342
1470
  .command("check-complete")
1343
1471
  .description("Evaluate exit triggers and report completion status")
@@ -1349,6 +1477,36 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1349
1477
  return cmd;
1350
1478
  }
1351
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
+
1352
1510
  /**
1353
1511
  * Entry point for `ov coordinator <subcommand>`.
1354
1512
  *