@os-eco/overstory-cli 0.7.8 → 0.8.0

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.
@@ -21,6 +21,8 @@ import { loadConfig } from "../config.ts";
21
21
  import { AgentError, ValidationError } from "../errors.ts";
22
22
  import { jsonOutput } from "../json.ts";
23
23
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
24
+ import { createMailClient } from "../mail/client.ts";
25
+ import { createMailStore } from "../mail/store.ts";
24
26
  import { getRuntime } from "../runtimes/registry.ts";
25
27
  import { openSessionStore } from "../sessions/compat.ts";
26
28
  import { createRunStore } from "../sessions/store.ts";
@@ -29,6 +31,7 @@ import type { AgentSession } from "../types.ts";
29
31
  import { isProcessRunning } from "../watchdog/health.ts";
30
32
  import type { SessionState } from "../worktree/tmux.ts";
31
33
  import {
34
+ capturePaneContent,
32
35
  checkSessionState,
33
36
  createSession,
34
37
  ensureTmuxAvailable,
@@ -37,11 +40,18 @@ import {
37
40
  sendKeys,
38
41
  waitForTuiReady,
39
42
  } from "../worktree/tmux.ts";
43
+ import { nudgeAgent } from "./nudge.ts";
40
44
  import { isRunningAsRoot } from "./sling.ts";
41
45
 
42
46
  /** Default coordinator agent name. */
43
47
  const COORDINATOR_NAME = "coordinator";
44
48
 
49
+ /** Poll interval for the ask subcommand reply loop. */
50
+ const ASK_POLL_INTERVAL_MS = 2_000;
51
+
52
+ /** Default timeout in seconds for the ask subcommand. */
53
+ const ASK_DEFAULT_TIMEOUT_S = 120;
54
+
45
55
  /**
46
56
  * Build the tmux session name for the coordinator.
47
57
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
@@ -81,6 +91,15 @@ export interface CoordinatorDeps {
81
91
  stop: () => Promise<boolean>;
82
92
  isRunning: () => Promise<boolean>;
83
93
  };
94
+ _nudge?: (
95
+ projectRoot: string,
96
+ agentName: string,
97
+ message: string,
98
+ force: boolean,
99
+ ) => Promise<{ delivered: boolean; reason?: string }>;
100
+ _capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
101
+ /** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
102
+ _pollIntervalMs?: number;
84
103
  }
85
104
 
86
105
  /**
@@ -738,6 +757,303 @@ async function statusCoordinator(
738
757
  }
739
758
  }
740
759
 
760
+ /**
761
+ * Send a fire-and-forget message to the running coordinator.
762
+ *
763
+ * Sends a mail message (from: operator, type: dispatch) and auto-nudges the
764
+ * coordinator via tmux sendKeys. Replaces the two-step `ov mail send + ov nudge` pattern.
765
+ */
766
+ async function sendToCoordinator(
767
+ body: string,
768
+ opts: { subject: string; json: boolean },
769
+ deps: CoordinatorDeps = {},
770
+ ): Promise<void> {
771
+ const tmux = deps._tmux ?? {
772
+ createSession,
773
+ isSessionAlive,
774
+ checkSessionState,
775
+ killSession,
776
+ sendKeys,
777
+ waitForTuiReady,
778
+ ensureTmuxAvailable,
779
+ };
780
+ const nudge = deps._nudge ?? nudgeAgent;
781
+
782
+ const { subject, json } = opts;
783
+ const cwd = process.cwd();
784
+ const config = await loadConfig(cwd);
785
+ const projectRoot = config.project.root;
786
+
787
+ const overstoryDir = join(projectRoot, ".overstory");
788
+ const { store } = openSessionStore(overstoryDir);
789
+ try {
790
+ const session = store.getByName(COORDINATOR_NAME);
791
+
792
+ if (
793
+ !session ||
794
+ session.capability !== "coordinator" ||
795
+ session.state === "completed" ||
796
+ session.state === "zombie"
797
+ ) {
798
+ throw new AgentError("No active coordinator session found", {
799
+ agentName: COORDINATOR_NAME,
800
+ });
801
+ }
802
+
803
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
804
+ if (!alive) {
805
+ store.updateState(COORDINATOR_NAME, "zombie");
806
+ store.updateLastActivity(COORDINATOR_NAME);
807
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
808
+ agentName: COORDINATOR_NAME,
809
+ });
810
+ }
811
+
812
+ // Send mail
813
+ const mailDbPath = join(overstoryDir, "mail.db");
814
+ const mailStore = createMailStore(mailDbPath);
815
+ const mailClient = createMailClient(mailStore);
816
+ let id: string;
817
+ try {
818
+ id = mailClient.send({
819
+ from: "operator",
820
+ to: COORDINATOR_NAME,
821
+ subject,
822
+ body,
823
+ type: "dispatch",
824
+ priority: "normal",
825
+ });
826
+ } finally {
827
+ mailClient.close();
828
+ }
829
+
830
+ // Auto-nudge (fire-and-forget)
831
+ const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
832
+ let nudged = false;
833
+ try {
834
+ const nudgeResult = await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
835
+ nudged = nudgeResult.delivered;
836
+ } catch {
837
+ // Nudge is fire-and-forget — silently ignore errors
838
+ }
839
+
840
+ if (json) {
841
+ jsonOutput("coordinator send", { id, nudged });
842
+ } else {
843
+ printSuccess("Sent to coordinator", id);
844
+ }
845
+ } finally {
846
+ store.close();
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Send a synchronous request to the coordinator and wait for a reply.
852
+ *
853
+ * Sends a mail message (from: operator, type: dispatch) with a correlationId,
854
+ * auto-nudges the coordinator via tmux, then polls mail.db for a reply in the
855
+ * same thread. Prints the reply body (or structured JSON) and exits.
856
+ * Throws AgentError if no reply arrives before the timeout.
857
+ */
858
+ export async function askCoordinator(
859
+ body: string,
860
+ opts: { subject: string; timeout: number; json: boolean },
861
+ deps: CoordinatorDeps = {},
862
+ ): Promise<void> {
863
+ const tmux = deps._tmux ?? {
864
+ createSession,
865
+ isSessionAlive,
866
+ checkSessionState,
867
+ killSession,
868
+ sendKeys,
869
+ waitForTuiReady,
870
+ ensureTmuxAvailable,
871
+ };
872
+ const nudge = deps._nudge ?? nudgeAgent;
873
+ const pollIntervalMs = deps._pollIntervalMs ?? ASK_POLL_INTERVAL_MS;
874
+
875
+ const { subject, timeout, json } = opts;
876
+ const cwd = process.cwd();
877
+ const config = await loadConfig(cwd);
878
+ const projectRoot = config.project.root;
879
+
880
+ const overstoryDir = join(projectRoot, ".overstory");
881
+ const { store } = openSessionStore(overstoryDir);
882
+ try {
883
+ const session = store.getByName(COORDINATOR_NAME);
884
+
885
+ if (
886
+ !session ||
887
+ session.capability !== "coordinator" ||
888
+ session.state === "completed" ||
889
+ session.state === "zombie"
890
+ ) {
891
+ throw new AgentError("No active coordinator session found", {
892
+ agentName: COORDINATOR_NAME,
893
+ });
894
+ }
895
+
896
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
897
+ if (!alive) {
898
+ store.updateState(COORDINATOR_NAME, "zombie");
899
+ store.updateLastActivity(COORDINATOR_NAME);
900
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
901
+ agentName: COORDINATOR_NAME,
902
+ });
903
+ }
904
+
905
+ // Generate correlation ID for tracking this request/response pair
906
+ const correlationId = crypto.randomUUID();
907
+
908
+ // Send mail with correlationId in payload
909
+ const mailDbPath = join(overstoryDir, "mail.db");
910
+ const mailStore = createMailStore(mailDbPath);
911
+ const mailClient = createMailClient(mailStore);
912
+ let sentId: string;
913
+ try {
914
+ sentId = mailClient.send({
915
+ from: "operator",
916
+ to: COORDINATOR_NAME,
917
+ subject,
918
+ body,
919
+ type: "dispatch",
920
+ priority: "normal",
921
+ payload: JSON.stringify({ correlationId }),
922
+ });
923
+ } finally {
924
+ mailClient.close();
925
+ }
926
+
927
+ // Auto-nudge (fire-and-forget)
928
+ const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
929
+ try {
930
+ await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
931
+ } catch {
932
+ // Nudge is fire-and-forget — silently ignore errors
933
+ }
934
+
935
+ // Poll for a reply in the same thread
936
+ const deadline = Date.now() + timeout * 1000;
937
+ while (Date.now() < deadline) {
938
+ await Bun.sleep(pollIntervalMs);
939
+ // Open a fresh store connection each cycle so we see the latest committed writes
940
+ const pollStore = createMailStore(mailDbPath);
941
+ let reply: import("../types.ts").MailMessage | undefined;
942
+ try {
943
+ const replies = pollStore.getByThread(sentId);
944
+ reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === "operator");
945
+ } finally {
946
+ pollStore.close();
947
+ }
948
+ if (reply) {
949
+ if (json) {
950
+ jsonOutput("coordinator ask", {
951
+ correlationId,
952
+ sentId,
953
+ replyId: reply.id,
954
+ subject: reply.subject,
955
+ body: reply.body,
956
+ payload: reply.payload,
957
+ });
958
+ } else {
959
+ process.stdout.write(`${reply.body}\n`);
960
+ }
961
+ return;
962
+ }
963
+ }
964
+
965
+ throw new AgentError(
966
+ `Timed out after ${timeout}s waiting for coordinator reply (correlationId: ${correlationId})`,
967
+ { agentName: COORDINATOR_NAME },
968
+ );
969
+ } finally {
970
+ store.close();
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Show recent coordinator tmux pane content without attaching.
976
+ *
977
+ * Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
978
+ */
979
+ async function outputCoordinator(
980
+ opts: { follow: boolean; lines: number; interval: number; json: boolean },
981
+ deps: CoordinatorDeps = {},
982
+ ): Promise<void> {
983
+ const tmux = deps._tmux ?? {
984
+ createSession,
985
+ isSessionAlive,
986
+ checkSessionState,
987
+ killSession,
988
+ sendKeys,
989
+ waitForTuiReady,
990
+ ensureTmuxAvailable,
991
+ };
992
+ const capturePane = deps._capturePaneContent ?? capturePaneContent;
993
+
994
+ const { follow, lines, interval, json } = opts;
995
+ const cwd = process.cwd();
996
+ const config = await loadConfig(cwd);
997
+ const projectRoot = config.project.root;
998
+
999
+ const overstoryDir = join(projectRoot, ".overstory");
1000
+ const { store } = openSessionStore(overstoryDir);
1001
+ try {
1002
+ const session = store.getByName(COORDINATOR_NAME);
1003
+
1004
+ if (
1005
+ !session ||
1006
+ session.capability !== "coordinator" ||
1007
+ session.state === "completed" ||
1008
+ session.state === "zombie"
1009
+ ) {
1010
+ throw new AgentError("No active coordinator session found", {
1011
+ agentName: COORDINATOR_NAME,
1012
+ });
1013
+ }
1014
+
1015
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
1016
+ if (!alive) {
1017
+ store.updateState(COORDINATOR_NAME, "zombie");
1018
+ store.updateLastActivity(COORDINATOR_NAME);
1019
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
1020
+ agentName: COORDINATOR_NAME,
1021
+ });
1022
+ }
1023
+
1024
+ const tmuxSession = session.tmuxSession;
1025
+
1026
+ if (follow) {
1027
+ // Set up SIGINT handler for clean exit
1028
+ let running = true;
1029
+ process.once("SIGINT", () => {
1030
+ running = false;
1031
+ });
1032
+
1033
+ while (running) {
1034
+ const content = await capturePane(tmuxSession, lines);
1035
+ if (json) {
1036
+ jsonOutput("coordinator output", { content, lines });
1037
+ } else {
1038
+ process.stdout.write(content ?? "");
1039
+ }
1040
+ if (running) {
1041
+ await Bun.sleep(interval);
1042
+ }
1043
+ }
1044
+ } else {
1045
+ const content = await capturePane(tmuxSession, lines);
1046
+ if (json) {
1047
+ jsonOutput("coordinator output", { content, lines });
1048
+ } else {
1049
+ process.stdout.write(content ?? "");
1050
+ }
1051
+ }
1052
+ } finally {
1053
+ store.close();
1054
+ }
1055
+ }
1056
+
741
1057
  /**
742
1058
  * Create the Commander command for `ov coordinator`.
743
1059
  */
@@ -784,6 +1100,56 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
784
1100
  await statusCoordinator({ json: opts.json ?? false }, deps);
785
1101
  });
786
1102
 
1103
+ cmd
1104
+ .command("send")
1105
+ .description("Send a message to the coordinator (fire-and-forget)")
1106
+ .requiredOption("--body <text>", "Message body")
1107
+ .option("--subject <text>", "Message subject", "operator dispatch")
1108
+ .option("--json", "Output as JSON")
1109
+ .action(async (opts: { body: string; subject: string; json?: boolean }) => {
1110
+ await sendToCoordinator(opts.body, { subject: opts.subject, json: opts.json ?? false }, deps);
1111
+ });
1112
+
1113
+ cmd
1114
+ .command("ask")
1115
+ .description("Send a request to the coordinator and wait for a reply")
1116
+ .requiredOption("--body <text>", "Message body")
1117
+ .option("--subject <text>", "Message subject", "operator request")
1118
+ .option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
1119
+ .option("--json", "Output as JSON")
1120
+ .action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
1121
+ await askCoordinator(
1122
+ opts.body,
1123
+ {
1124
+ subject: opts.subject,
1125
+ timeout: Number.parseInt(opts.timeout ?? String(ASK_DEFAULT_TIMEOUT_S), 10),
1126
+ json: opts.json ?? false,
1127
+ },
1128
+ deps,
1129
+ );
1130
+ });
1131
+
1132
+ cmd
1133
+ .command("output")
1134
+ .description("Show recent coordinator output (tmux pane content)")
1135
+ .option("--follow, -f", "Continuously poll for new output")
1136
+ .option("--lines <n>", "Number of lines to capture", "50")
1137
+ .option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
1138
+ .option("--json", "Output as JSON")
1139
+ .action(
1140
+ async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
1141
+ await outputCoordinator(
1142
+ {
1143
+ follow: opts.follow ?? false,
1144
+ lines: Number.parseInt(opts.lines ?? "50", 10),
1145
+ interval: Number.parseInt(opts.interval ?? "2000", 10),
1146
+ json: opts.json ?? false,
1147
+ },
1148
+ deps,
1149
+ );
1150
+ },
1151
+ );
1152
+
787
1153
  return cmd;
788
1154
  }
789
1155
 
@@ -26,6 +26,7 @@ const AGENT_DEF_FILES = [
26
26
  "merger.md",
27
27
  "coordinator.md",
28
28
  "monitor.md",
29
+ "orchestrator.md",
29
30
  ];
30
31
 
31
32
  /** Resolve the source agents directory (same logic as init.ts). */
@@ -52,7 +53,7 @@ describe("initCommand: agent-defs deployment", () => {
52
53
  await cleanupTempDir(tempDir);
53
54
  });
54
55
 
55
- test("creates .overstory/agent-defs/ with all 7 agent definition files (supervisor deprecated)", async () => {
56
+ test("creates .overstory/agent-defs/ with all 8 agent definition files (supervisor deprecated)", async () => {
56
57
  await initCommand({ _spawner: noopSpawner });
57
58
 
58
59
  const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
@@ -166,6 +167,7 @@ describe("initCommand: .overstory/.gitignore", () => {
166
167
  expect(content).toContain("!hooks.json\n");
167
168
  expect(content).toContain("!groups.json\n");
168
169
  expect(content).toContain("!agent-defs/\n");
170
+ expect(content).toContain("!agent-defs/**\n");
169
171
 
170
172
  // Verify it matches the exported constant
171
173
  expect(content).toBe(OVERSTORY_GITIGNORE);
@@ -324,7 +324,7 @@ function formatYamlValue(value: unknown): string {
324
324
  /**
325
325
  * Build the starter agent manifest.
326
326
  */
327
- function buildAgentManifest(): AgentManifest {
327
+ export function buildAgentManifest(): AgentManifest {
328
328
  const agents: AgentManifest["agents"] = {
329
329
  scout: {
330
330
  file: "scout.md",
@@ -407,7 +407,7 @@ function buildAgentManifest(): AgentManifest {
407
407
  * {{AGENT_NAME}} placeholders and space indentation). Uses tab indentation
408
408
  * to match Biome formatting rules.
409
409
  */
410
- function buildHooksJson(): string {
410
+ export function buildHooksJson(): string {
411
411
  // Tool name extraction: reads hook stdin JSON and extracts tool_name field.
412
412
  // Claude Code sends {"tool_name":"Bash","tool_input":{...}} on stdin for
413
413
  // PreToolUse/PostToolUse hooks.
@@ -588,6 +588,7 @@ export const OVERSTORY_GITIGNORE = `# Wildcard+whitelist: ignore everything, whi
588
588
  !hooks.json
589
589
  !groups.json
590
590
  !agent-defs/
591
+ !agent-defs/**
591
592
  !README.md
592
593
  `;
593
594
 
@@ -366,6 +366,7 @@ recentTasks: []
366
366
  !hooks.json
367
367
  !groups.json
368
368
  !agent-defs/
369
+ !agent-defs/**
369
370
  !README.md
370
371
  `;
371
372