@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.
- package/README.md +20 -6
- package/agents/coordinator.md +30 -6
- package/agents/lead.md +11 -1
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +18 -4
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +226 -124
- package/src/commands/dashboard.ts +46 -9
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.ts +8 -0
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +3 -3
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +4 -79
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +4 -1
- package/src/mail/store.test.ts +110 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +9 -9
- package/src/runtimes/pi.ts +6 -7
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +4 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +46 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- 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:
|
|
277
|
-
"DELEGATION: For
|
|
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 ===
|
|
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
|
-
|
|
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,
|
|
398
|
-
const runtime = getRuntime(undefined, config,
|
|
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
|
|
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
|
|
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",
|
|
439
|
-
const
|
|
471
|
+
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", agentDefFile);
|
|
472
|
+
const agentDefHandle = Bun.file(agentDefPath);
|
|
440
473
|
let appendSystemPromptFile: string | undefined;
|
|
441
|
-
if (await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
624
|
+
jsonOutput(`${capability} start`, output);
|
|
592
625
|
} else {
|
|
593
|
-
printSuccess(
|
|
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
|
|
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
|
-
|
|
617
|
-
|
|
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
|
|
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(
|
|
705
|
+
const session = store.getByName(spec.agentName);
|
|
653
706
|
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
|
|
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(
|
|
679
|
-
store.updateLastActivity(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
822
|
+
jsonOutput(`${spec.commandName} status`, {
|
|
823
|
+
running: false,
|
|
824
|
+
watchdogRunning,
|
|
825
|
+
monitorRunning,
|
|
826
|
+
});
|
|
779
827
|
} else {
|
|
780
|
-
printHint(
|
|
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(
|
|
798
|
-
store.updateLastActivity(
|
|
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(
|
|
863
|
+
jsonOutput(`${spec.commandName} status`, status);
|
|
816
864
|
} else {
|
|
817
865
|
const stateLabel = alive ? "running" : session.state;
|
|
818
|
-
process.stdout.write(
|
|
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
|
|
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(
|
|
911
|
+
const session = store.getByName(spec.agentName);
|
|
863
912
|
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
|
|
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(
|
|
878
|
-
store.updateLastActivity(
|
|
879
|
-
throw new AgentError(
|
|
880
|
-
|
|
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:
|
|
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,
|
|
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(
|
|
960
|
+
jsonOutput(`${spec.commandName} send`, { id, nudged });
|
|
914
961
|
} else {
|
|
915
|
-
printSuccess(
|
|
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(
|
|
1011
|
+
const session = store.getByName(spec.agentName);
|
|
956
1012
|
|
|
957
|
-
if (
|
|
958
|
-
|
|
959
|
-
|
|
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(
|
|
971
|
-
store.updateLastActivity(
|
|
972
|
-
throw new AgentError(
|
|
973
|
-
|
|
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:
|
|
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,
|
|
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 ===
|
|
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(
|
|
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
|
|
1039
|
-
{ agentName:
|
|
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
|
|
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(
|
|
1129
|
+
const session = store.getByName(spec.agentName);
|
|
1075
1130
|
|
|
1076
|
-
if (
|
|
1077
|
-
|
|
1078
|
-
|
|
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(
|
|
1090
|
-
store.updateLastActivity(
|
|
1091
|
-
throw new AgentError(
|
|
1092
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
const cmd = new Command(
|
|
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(
|
|
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",
|
|
1305
|
-
.option("--monitor",
|
|
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
|
|
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(
|
|
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
|
|
1392
|
+
await stopPersistentAgent(spec, { json: opts.json ?? false }, deps);
|
|
1337
1393
|
});
|
|
1338
1394
|
|
|
1339
1395
|
cmd
|
|
1340
1396
|
.command("status")
|
|
1341
|
-
.description(
|
|
1397
|
+
.description(`Show ${spec.commandName} state`)
|
|
1342
1398
|
.option("--json", "Output as JSON")
|
|
1343
1399
|
.action(async (opts: { json?: boolean }) => {
|
|
1344
|
-
await
|
|
1400
|
+
await statusPersistentAgent(spec, { json: opts.json ?? false }, deps);
|
|
1345
1401
|
});
|
|
1346
1402
|
|
|
1347
1403
|
cmd
|
|
1348
1404
|
.command("send")
|
|
1349
|
-
.description(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
*
|