@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tests for src/commands/monitor.ts
3
+ *
4
+ * Note: We do NOT test start/stop/status subcommands here because they require
5
+ * tmux session management, which is fragile in test environments and interferes
6
+ * with developer tmux sessions. Those operations are covered by E2E testing.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
10
+ import { mkdir } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { AgentError, ValidationError } from "../errors.ts";
13
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
14
+ import { buildMonitorBeacon, monitorCommand } from "./monitor.ts";
15
+ import { isRunningAsRoot } from "./sling.ts";
16
+
17
+ describe("buildMonitorBeacon", () => {
18
+ test("contains monitor agent name", () => {
19
+ const beacon = buildMonitorBeacon();
20
+ expect(beacon).toContain("monitor");
21
+ });
22
+
23
+ test("contains tier-2 designation", () => {
24
+ const beacon = buildMonitorBeacon();
25
+ expect(beacon).toContain("tier-2");
26
+ });
27
+
28
+ test("contains ISO timestamp with today's date", () => {
29
+ const beacon = buildMonitorBeacon();
30
+ const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
31
+ if (!today) {
32
+ throw new Error("Failed to extract date from ISO string");
33
+ }
34
+ expect(beacon).toContain(today);
35
+ });
36
+
37
+ test("contains startup instruction: mulch prime", () => {
38
+ const beacon = buildMonitorBeacon();
39
+ expect(beacon).toContain("mulch prime");
40
+ });
41
+
42
+ test("contains startup instruction: overstory status --json", () => {
43
+ const beacon = buildMonitorBeacon();
44
+ expect(beacon).toContain("overstory status --json");
45
+ });
46
+
47
+ test("contains startup instruction: overstory mail check --agent monitor", () => {
48
+ const beacon = buildMonitorBeacon();
49
+ expect(beacon).toContain("overstory mail check --agent monitor");
50
+ });
51
+
52
+ test("contains startup instruction: patrol loop", () => {
53
+ const beacon = buildMonitorBeacon();
54
+ expect(beacon).toContain("patrol loop");
55
+ });
56
+
57
+ test("contains [OVERSTORY] prefix", () => {
58
+ const beacon = buildMonitorBeacon();
59
+ expect(beacon).toContain("[OVERSTORY]");
60
+ });
61
+
62
+ test("contains Depth: 0", () => {
63
+ const beacon = buildMonitorBeacon();
64
+ expect(beacon).toContain("Depth: 0");
65
+ });
66
+
67
+ test("contains Parent: none", () => {
68
+ const beacon = buildMonitorBeacon();
69
+ expect(beacon).toContain("Parent: none");
70
+ });
71
+ });
72
+
73
+ describe("monitorCommand", () => {
74
+ let stdoutSpy: ReturnType<typeof spyOn>;
75
+ let stdoutWrites: string[] = [];
76
+
77
+ beforeEach(() => {
78
+ stdoutWrites = [];
79
+ stdoutSpy = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
80
+ stdoutWrites.push(String(chunk));
81
+ return true;
82
+ });
83
+ });
84
+
85
+ afterEach(() => {
86
+ stdoutSpy.mockRestore();
87
+ });
88
+
89
+ test("--help prints help text containing 'overstory monitor'", async () => {
90
+ await monitorCommand(["--help"]);
91
+ const output = stdoutWrites.join("");
92
+ expect(output).toContain("overstory monitor");
93
+ });
94
+
95
+ test("--help prints help text containing 'start'", async () => {
96
+ await monitorCommand(["--help"]);
97
+ const output = stdoutWrites.join("");
98
+ expect(output).toContain("start");
99
+ });
100
+
101
+ test("--help prints help text containing 'stop'", async () => {
102
+ await monitorCommand(["--help"]);
103
+ const output = stdoutWrites.join("");
104
+ expect(output).toContain("stop");
105
+ });
106
+
107
+ test("--help prints help text containing 'status'", async () => {
108
+ await monitorCommand(["--help"]);
109
+ const output = stdoutWrites.join("");
110
+ expect(output).toContain("status");
111
+ });
112
+
113
+ test("-h prints help text", async () => {
114
+ await monitorCommand(["-h"]);
115
+ const output = stdoutWrites.join("");
116
+ expect(output).toContain("overstory monitor");
117
+ });
118
+
119
+ test("empty args [] shows help (same as --help)", async () => {
120
+ await monitorCommand([]);
121
+ const output = stdoutWrites.join("");
122
+ expect(output).toContain("overstory monitor");
123
+ });
124
+
125
+ test("unknown subcommand 'restart' throws ValidationError", async () => {
126
+ await expect(monitorCommand(["restart"])).rejects.toThrow(ValidationError);
127
+ });
128
+
129
+ test("unknown subcommand error message contains the bad value 'restart'", async () => {
130
+ try {
131
+ await monitorCommand(["restart"]);
132
+ // Should not reach here
133
+ expect(true).toBe(false);
134
+ } catch (err) {
135
+ if (err instanceof ValidationError) {
136
+ expect(err.message).toContain("restart");
137
+ } else {
138
+ throw err;
139
+ }
140
+ }
141
+ });
142
+
143
+ describe("tier2Enabled gate", () => {
144
+ let tempDir: string;
145
+ const originalCwd = process.cwd();
146
+
147
+ beforeEach(async () => {
148
+ process.chdir(originalCwd);
149
+ tempDir = await createTempGitRepo();
150
+ const overstoryDir = join(tempDir, ".overstory");
151
+ await mkdir(overstoryDir, { recursive: true });
152
+ // Write minimal config — tier2Enabled defaults to false
153
+ await Bun.write(
154
+ join(overstoryDir, "config.yaml"),
155
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
156
+ "\n",
157
+ ),
158
+ );
159
+ process.chdir(tempDir);
160
+ });
161
+
162
+ afterEach(async () => {
163
+ process.chdir(originalCwd);
164
+ await cleanupTempDir(tempDir);
165
+ });
166
+
167
+ test("monitor start throws AgentError when tier2Enabled is false (default)", async () => {
168
+ await expect(monitorCommand(["start"])).rejects.toThrow(AgentError);
169
+ });
170
+
171
+ test("monitor start error message contains 'disabled' when tier2Enabled is false", async () => {
172
+ try {
173
+ await monitorCommand(["start"]);
174
+ expect(true).toBe(false); // Should not reach here
175
+ } catch (err) {
176
+ if (err instanceof AgentError) {
177
+ expect(err.message).toContain("disabled");
178
+ } else {
179
+ throw err;
180
+ }
181
+ }
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("isRunningAsRoot (imported from sling)", () => {
187
+ test("is accessible from monitor test file", () => {
188
+ expect(isRunningAsRoot(() => 0)).toBe(true);
189
+ expect(isRunningAsRoot(() => 1000)).toBe(false);
190
+ });
191
+ });
@@ -0,0 +1,390 @@
1
+ /**
2
+ * CLI command: overstory monitor start|stop|status
3
+ *
4
+ * Manages the persistent Tier 2 monitor agent lifecycle. The monitor runs
5
+ * at the project root (NOT in a worktree), continuously patrols the agent
6
+ * fleet, sends nudges to stalled agents, and reports health summaries to
7
+ * the coordinator.
8
+ *
9
+ * Unlike regular agents spawned by sling, the monitor:
10
+ * - Has no worktree (operates on the main working tree)
11
+ * - Has no bead assignment (it monitors, not implements)
12
+ * - Has no overlay CLAUDE.md (context comes via overstory status + mail)
13
+ * - Persists across patrol cycles
14
+ */
15
+
16
+ import { mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { deployHooks } from "../agents/hooks-deployer.ts";
19
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
20
+ import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
21
+ import { loadConfig } from "../config.ts";
22
+ import { AgentError, ValidationError } from "../errors.ts";
23
+ import { openSessionStore } from "../sessions/compat.ts";
24
+ import type { AgentSession } from "../types.ts";
25
+ import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
26
+ import { isRunningAsRoot } from "./sling.ts";
27
+
28
+ /** Default monitor agent name. */
29
+ const MONITOR_NAME = "monitor";
30
+
31
+ /**
32
+ * Build the tmux session name for the monitor.
33
+ * Includes the project name to prevent cross-project collisions (overstory-pcef).
34
+ */
35
+ function monitorTmuxSession(projectName: string): string {
36
+ return `overstory-${projectName}-${MONITOR_NAME}`;
37
+ }
38
+
39
+ /**
40
+ * Build the monitor startup beacon — the first message sent to the monitor
41
+ * via tmux send-keys after Claude Code initializes.
42
+ */
43
+ export function buildMonitorBeacon(): string {
44
+ const timestamp = new Date().toISOString();
45
+ const parts = [
46
+ `[OVERSTORY] ${MONITOR_NAME} (monitor/tier-2) ${timestamp}`,
47
+ "Depth: 0 | Parent: none | Role: continuous fleet patrol",
48
+ `Startup: run mulch prime, check fleet (overstory status --json), check mail (overstory mail check --agent ${MONITOR_NAME}), then begin patrol loop`,
49
+ ];
50
+ return parts.join(" — ");
51
+ }
52
+
53
+ /**
54
+ * Determine whether to auto-attach to the tmux session after starting.
55
+ */
56
+ function resolveAttach(args: string[], isTTY: boolean): boolean {
57
+ if (args.includes("--attach")) return true;
58
+ if (args.includes("--no-attach")) return false;
59
+ return isTTY;
60
+ }
61
+
62
+ /**
63
+ * Start the monitor agent.
64
+ *
65
+ * 1. Verify no monitor is already running
66
+ * 2. Load config
67
+ * 3. Deploy hooks to project root's .claude/ (monitor-specific guards)
68
+ * 4. Create agent identity (if first time)
69
+ * 5. Spawn tmux session at project root with Claude Code
70
+ * 6. Send startup beacon
71
+ * 7. Record session in SessionStore (sessions.db)
72
+ */
73
+ async function startMonitor(args: string[]): Promise<void> {
74
+ const json = args.includes("--json");
75
+ const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
76
+
77
+ if (isRunningAsRoot()) {
78
+ throw new AgentError(
79
+ "Cannot spawn agents as root (UID 0). The claude CLI rejects --dangerously-skip-permissions when run as root, causing the tmux session to die immediately. Run overstory as a non-root user.",
80
+ );
81
+ }
82
+
83
+ const cwd = process.cwd();
84
+ const config = await loadConfig(cwd);
85
+
86
+ // Gate on tier2Enabled config flag
87
+ if (!config.watchdog.tier2Enabled) {
88
+ throw new AgentError(
89
+ "Monitor agent (Tier 2) is disabled. Set watchdog.tier2Enabled: true in .overstory/config.yaml to enable.",
90
+ { agentName: MONITOR_NAME },
91
+ );
92
+ }
93
+
94
+ const projectRoot = config.project.root;
95
+ const tmuxSession = monitorTmuxSession(config.project.name);
96
+
97
+ // Check for existing monitor
98
+ const overstoryDir = join(projectRoot, ".overstory");
99
+ const { store } = openSessionStore(overstoryDir);
100
+ try {
101
+ const existing = store.getByName(MONITOR_NAME);
102
+
103
+ if (
104
+ existing &&
105
+ existing.capability === "monitor" &&
106
+ existing.state !== "completed" &&
107
+ existing.state !== "zombie"
108
+ ) {
109
+ const alive = await isSessionAlive(existing.tmuxSession);
110
+ if (alive) {
111
+ throw new AgentError(
112
+ `Monitor is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
113
+ { agentName: MONITOR_NAME },
114
+ );
115
+ }
116
+ // Session recorded but tmux is dead — mark as completed and continue
117
+ store.updateState(MONITOR_NAME, "completed");
118
+ }
119
+
120
+ // Deploy monitor-specific hooks to the project root's .claude/ directory.
121
+ // The monitor gets the same structural enforcement as other non-implementation
122
+ // agents (Write/Edit/NotebookEdit blocked, dangerous bash commands blocked).
123
+ await deployHooks(projectRoot, MONITOR_NAME, "monitor");
124
+
125
+ // Create monitor identity if first run
126
+ const identityBaseDir = join(projectRoot, ".overstory", "agents");
127
+ await mkdir(identityBaseDir, { recursive: true });
128
+ const existingIdentity = await loadIdentity(identityBaseDir, MONITOR_NAME);
129
+ if (!existingIdentity) {
130
+ await createIdentity(identityBaseDir, {
131
+ name: MONITOR_NAME,
132
+ capability: "monitor",
133
+ created: new Date().toISOString(),
134
+ sessionsCompleted: 0,
135
+ expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
136
+ recentTasks: [],
137
+ });
138
+ }
139
+
140
+ // Resolve model from config > manifest > fallback
141
+ const manifestLoader = createManifestLoader(
142
+ join(projectRoot, config.agents.manifestPath),
143
+ join(projectRoot, config.agents.baseDir),
144
+ );
145
+ const manifest = await manifestLoader.load();
146
+ const { model, env } = resolveModel(config, manifest, "monitor", "sonnet");
147
+
148
+ // Spawn tmux session at project root with Claude Code (interactive mode).
149
+ // Inject the monitor base definition via --append-system-prompt.
150
+ const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "monitor.md");
151
+ const agentDefFile = Bun.file(agentDefPath);
152
+ let claudeCmd = `claude --model ${model} --dangerously-skip-permissions`;
153
+ if (await agentDefFile.exists()) {
154
+ const agentDef = await agentDefFile.text();
155
+ const escaped = agentDef.replace(/'/g, "'\\''");
156
+ claudeCmd += ` --append-system-prompt '${escaped}'`;
157
+ }
158
+ const pid = await createSession(tmuxSession, projectRoot, claudeCmd, {
159
+ ...env,
160
+ OVERSTORY_AGENT_NAME: MONITOR_NAME,
161
+ });
162
+
163
+ // Record session BEFORE sending the beacon so that hook-triggered
164
+ // updateLastActivity() can find the entry and transition booting->working.
165
+ const session: AgentSession = {
166
+ id: `session-${Date.now()}-${MONITOR_NAME}`,
167
+ agentName: MONITOR_NAME,
168
+ capability: "monitor",
169
+ worktreePath: projectRoot, // Monitor uses project root, not a worktree
170
+ branchName: config.project.canonicalBranch, // Operates on canonical branch
171
+ beadId: "", // No specific bead assignment
172
+ tmuxSession,
173
+ state: "booting",
174
+ pid,
175
+ parentAgent: null, // Top of hierarchy (alongside coordinator)
176
+ depth: 0,
177
+ runId: null,
178
+ startedAt: new Date().toISOString(),
179
+ lastActivity: new Date().toISOString(),
180
+ escalationLevel: 0,
181
+ stalledSince: null,
182
+ };
183
+
184
+ store.upsert(session);
185
+
186
+ // Send beacon after TUI initialization delay
187
+ await Bun.sleep(3_000);
188
+ const beacon = buildMonitorBeacon();
189
+ await sendKeys(tmuxSession, beacon);
190
+
191
+ // Follow-up Enter to ensure submission (same pattern as sling.ts)
192
+ await Bun.sleep(500);
193
+ await sendKeys(tmuxSession, "");
194
+
195
+ const output = {
196
+ agentName: MONITOR_NAME,
197
+ capability: "monitor",
198
+ tmuxSession,
199
+ projectRoot,
200
+ pid,
201
+ };
202
+
203
+ if (json) {
204
+ process.stdout.write(`${JSON.stringify(output)}\n`);
205
+ } else {
206
+ process.stdout.write("Monitor started\n");
207
+ process.stdout.write(` Tmux: ${tmuxSession}\n`);
208
+ process.stdout.write(` Root: ${projectRoot}\n`);
209
+ process.stdout.write(` PID: ${pid}\n`);
210
+ }
211
+
212
+ if (shouldAttach) {
213
+ Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
214
+ stdio: ["inherit", "inherit", "inherit"],
215
+ });
216
+ }
217
+ } finally {
218
+ store.close();
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Stop the monitor agent.
224
+ *
225
+ * 1. Find the active monitor session
226
+ * 2. Kill the tmux session (with process tree cleanup)
227
+ * 3. Mark session as completed in SessionStore
228
+ */
229
+ async function stopMonitor(args: string[]): Promise<void> {
230
+ const json = args.includes("--json");
231
+ const cwd = process.cwd();
232
+ const config = await loadConfig(cwd);
233
+ const projectRoot = config.project.root;
234
+
235
+ const overstoryDir = join(projectRoot, ".overstory");
236
+ const { store } = openSessionStore(overstoryDir);
237
+ try {
238
+ const session = store.getByName(MONITOR_NAME);
239
+
240
+ if (
241
+ !session ||
242
+ session.capability !== "monitor" ||
243
+ session.state === "completed" ||
244
+ session.state === "zombie"
245
+ ) {
246
+ throw new AgentError("No active monitor session found", {
247
+ agentName: MONITOR_NAME,
248
+ });
249
+ }
250
+
251
+ // Kill tmux session with process tree cleanup
252
+ const alive = await isSessionAlive(session.tmuxSession);
253
+ if (alive) {
254
+ await killSession(session.tmuxSession);
255
+ }
256
+
257
+ // Update session state
258
+ store.updateState(MONITOR_NAME, "completed");
259
+ store.updateLastActivity(MONITOR_NAME);
260
+
261
+ if (json) {
262
+ process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
263
+ } else {
264
+ process.stdout.write(`Monitor stopped (session: ${session.id})\n`);
265
+ }
266
+ } finally {
267
+ store.close();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Show monitor status.
273
+ *
274
+ * Checks session registry and tmux liveness to report actual state.
275
+ */
276
+ async function statusMonitor(args: string[]): Promise<void> {
277
+ const json = args.includes("--json");
278
+ const cwd = process.cwd();
279
+ const config = await loadConfig(cwd);
280
+ const projectRoot = config.project.root;
281
+
282
+ const overstoryDir = join(projectRoot, ".overstory");
283
+ const { store } = openSessionStore(overstoryDir);
284
+ try {
285
+ const session = store.getByName(MONITOR_NAME);
286
+
287
+ if (
288
+ !session ||
289
+ session.capability !== "monitor" ||
290
+ session.state === "completed" ||
291
+ session.state === "zombie"
292
+ ) {
293
+ if (json) {
294
+ process.stdout.write(`${JSON.stringify({ running: false })}\n`);
295
+ } else {
296
+ process.stdout.write("Monitor is not running\n");
297
+ }
298
+ return;
299
+ }
300
+
301
+ const alive = await isSessionAlive(session.tmuxSession);
302
+
303
+ // Reconcile state: if session says active but tmux is dead, update.
304
+ // We already filtered out completed/zombie states above, so if tmux is dead
305
+ // this session needs to be marked as zombie.
306
+ if (!alive) {
307
+ store.updateState(MONITOR_NAME, "zombie");
308
+ store.updateLastActivity(MONITOR_NAME);
309
+ session.state = "zombie";
310
+ }
311
+
312
+ const status = {
313
+ running: alive,
314
+ sessionId: session.id,
315
+ state: session.state,
316
+ tmuxSession: session.tmuxSession,
317
+ pid: session.pid,
318
+ startedAt: session.startedAt,
319
+ lastActivity: session.lastActivity,
320
+ };
321
+
322
+ if (json) {
323
+ process.stdout.write(`${JSON.stringify(status)}\n`);
324
+ } else {
325
+ const stateLabel = alive ? "running" : session.state;
326
+ process.stdout.write(`Monitor: ${stateLabel}\n`);
327
+ process.stdout.write(` Session: ${session.id}\n`);
328
+ process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
329
+ process.stdout.write(` PID: ${session.pid}\n`);
330
+ process.stdout.write(` Started: ${session.startedAt}\n`);
331
+ process.stdout.write(` Activity: ${session.lastActivity}\n`);
332
+ }
333
+ } finally {
334
+ store.close();
335
+ }
336
+ }
337
+
338
+ const MONITOR_HELP = `overstory monitor — Manage the persistent Tier 2 monitor agent
339
+
340
+ Usage: overstory monitor <subcommand> [flags]
341
+
342
+ Subcommands:
343
+ start Start the monitor (spawns Claude Code at project root)
344
+ stop Stop the monitor (kills tmux session)
345
+ status Show monitor state
346
+
347
+ Start options:
348
+ --attach Always attach to tmux session after start
349
+ --no-attach Never attach to tmux session after start
350
+ Default: attach when running in an interactive TTY
351
+
352
+ General options:
353
+ --json Output as JSON
354
+ --help, -h Show this help
355
+
356
+ The monitor agent (Tier 2) continuously patrols the agent fleet by:
357
+ - Checking agent health via overstory status
358
+ - Sending progressive nudges to stalled agents
359
+ - Escalating unresponsive agents to the coordinator
360
+ - Producing periodic health summaries`;
361
+
362
+ /**
363
+ * Entry point for `overstory monitor <subcommand>`.
364
+ */
365
+ export async function monitorCommand(args: string[]): Promise<void> {
366
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
367
+ process.stdout.write(`${MONITOR_HELP}\n`);
368
+ return;
369
+ }
370
+
371
+ const subcommand = args[0];
372
+ const subArgs = args.slice(1);
373
+
374
+ switch (subcommand) {
375
+ case "start":
376
+ await startMonitor(subArgs);
377
+ break;
378
+ case "stop":
379
+ await stopMonitor(subArgs);
380
+ break;
381
+ case "status":
382
+ await statusMonitor(subArgs);
383
+ break;
384
+ default:
385
+ throw new ValidationError(
386
+ `Unknown monitor subcommand: ${subcommand}. Run 'overstory monitor --help' for usage.`,
387
+ { field: "subcommand", value: subcommand },
388
+ );
389
+ }
390
+ }