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