@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,733 @@
1
+ /**
2
+ * CLI command: overstory coordinator start|stop|status
3
+ *
4
+ * Manages the persistent coordinator agent lifecycle. The coordinator runs
5
+ * at the project root (NOT in a worktree), receives work via mail and beads,
6
+ * and dispatches agents via overstory sling.
7
+ *
8
+ * Unlike regular agents spawned by sling, the coordinator:
9
+ * - Has no worktree (operates on the main working tree)
10
+ * - Has no bead assignment (it creates beads, not works on them)
11
+ * - Has no overlay CLAUDE.md (context comes via mail + beads + checkpoints)
12
+ * - Persists across work batches
13
+ */
14
+
15
+ import { mkdir, unlink } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { deployHooks } from "../agents/hooks-deployer.ts";
18
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
19
+ import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
20
+ import { loadConfig } from "../config.ts";
21
+ import { AgentError, ValidationError } from "../errors.ts";
22
+ import { openSessionStore } from "../sessions/compat.ts";
23
+ import { createRunStore } from "../sessions/store.ts";
24
+ import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
25
+ import type { AgentSession } from "../types.ts";
26
+ import { isProcessRunning } from "../watchdog/health.ts";
27
+ import {
28
+ createSession,
29
+ isSessionAlive,
30
+ killSession,
31
+ sendKeys,
32
+ waitForTuiReady,
33
+ } from "../worktree/tmux.ts";
34
+ import { isRunningAsRoot } from "./sling.ts";
35
+
36
+ /** Default coordinator agent name. */
37
+ const COORDINATOR_NAME = "coordinator";
38
+
39
+ /**
40
+ * Build the tmux session name for the coordinator.
41
+ * Includes the project name to prevent cross-project collisions (overstory-pcef).
42
+ */
43
+ function coordinatorTmuxSession(projectName: string): string {
44
+ return `overstory-${projectName}-${COORDINATOR_NAME}`;
45
+ }
46
+
47
+ /** Dependency injection for testing. Uses real implementations when omitted. */
48
+ export interface CoordinatorDeps {
49
+ _tmux?: {
50
+ createSession: (
51
+ name: string,
52
+ cwd: string,
53
+ command: string,
54
+ env?: Record<string, string>,
55
+ ) => Promise<number>;
56
+ isSessionAlive: (name: string) => Promise<boolean>;
57
+ killSession: (name: string) => Promise<void>;
58
+ sendKeys: (name: string, keys: string) => Promise<void>;
59
+ waitForTuiReady: (
60
+ name: string,
61
+ timeoutMs?: number,
62
+ pollIntervalMs?: number,
63
+ ) => Promise<boolean>;
64
+ };
65
+ _watchdog?: {
66
+ start: () => Promise<{ pid: number } | null>;
67
+ stop: () => Promise<boolean>;
68
+ isRunning: () => Promise<boolean>;
69
+ };
70
+ _monitor?: {
71
+ start: (args: string[]) => Promise<{ pid: number } | null>;
72
+ stop: () => Promise<boolean>;
73
+ isRunning: () => Promise<boolean>;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Read the PID from the watchdog PID file.
79
+ * Returns null if the file doesn't exist or can't be parsed.
80
+ */
81
+ async function readWatchdogPid(projectRoot: string): Promise<number | null> {
82
+ const pidFilePath = join(projectRoot, ".overstory", "watchdog.pid");
83
+ const file = Bun.file(pidFilePath);
84
+ const exists = await file.exists();
85
+ if (!exists) {
86
+ return null;
87
+ }
88
+
89
+ try {
90
+ const text = await file.text();
91
+ const pid = Number.parseInt(text.trim(), 10);
92
+ if (Number.isNaN(pid) || pid <= 0) {
93
+ return null;
94
+ }
95
+ return pid;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Remove the watchdog PID file.
103
+ */
104
+ async function removeWatchdogPid(projectRoot: string): Promise<void> {
105
+ const pidFilePath = join(projectRoot, ".overstory", "watchdog.pid");
106
+ try {
107
+ await unlink(pidFilePath);
108
+ } catch {
109
+ // File may already be gone — not an error
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Default watchdog implementation for production use.
115
+ * Starts/stops the watchdog daemon via `overstory watch --background`.
116
+ */
117
+ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
118
+ return {
119
+ async start(): Promise<{ pid: number } | null> {
120
+ // Check if watchdog is already running
121
+ const existingPid = await readWatchdogPid(projectRoot);
122
+ if (existingPid !== null && isProcessRunning(existingPid)) {
123
+ return null; // Already running
124
+ }
125
+
126
+ // Clean up stale PID file
127
+ if (existingPid !== null) {
128
+ await removeWatchdogPid(projectRoot);
129
+ }
130
+
131
+ // Start watchdog in background
132
+ const proc = Bun.spawn(["overstory", "watch", "--background"], {
133
+ cwd: projectRoot,
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ });
137
+
138
+ const exitCode = await proc.exited;
139
+ if (exitCode !== 0) {
140
+ return null; // Failed to start
141
+ }
142
+
143
+ // Read the PID file that was written by the background process
144
+ const pid = await readWatchdogPid(projectRoot);
145
+ if (pid === null) {
146
+ return null; // PID file wasn't created
147
+ }
148
+
149
+ return { pid };
150
+ },
151
+
152
+ async stop(): Promise<boolean> {
153
+ const pid = await readWatchdogPid(projectRoot);
154
+ if (pid === null) {
155
+ return false; // No PID file
156
+ }
157
+
158
+ // Check if process is running
159
+ if (!isProcessRunning(pid)) {
160
+ // Process is dead, clean up PID file
161
+ await removeWatchdogPid(projectRoot);
162
+ return false;
163
+ }
164
+
165
+ // Kill the process
166
+ try {
167
+ process.kill(pid, 15); // SIGTERM
168
+ } catch {
169
+ return false;
170
+ }
171
+
172
+ // Remove PID file
173
+ await removeWatchdogPid(projectRoot);
174
+ return true;
175
+ },
176
+
177
+ async isRunning(): Promise<boolean> {
178
+ const pid = await readWatchdogPid(projectRoot);
179
+ if (pid === null) {
180
+ return false;
181
+ }
182
+ return isProcessRunning(pid);
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Default monitor implementation for production use.
189
+ * Starts/stops the monitor agent via `overstory monitor start/stop`.
190
+ */
191
+ function createDefaultMonitor(projectRoot: string): NonNullable<CoordinatorDeps["_monitor"]> {
192
+ return {
193
+ async start(): Promise<{ pid: number } | null> {
194
+ const proc = Bun.spawn(["overstory", "monitor", "start", "--no-attach", "--json"], {
195
+ cwd: projectRoot,
196
+ stdout: "pipe",
197
+ stderr: "pipe",
198
+ });
199
+ const exitCode = await proc.exited;
200
+ if (exitCode !== 0) return null;
201
+ try {
202
+ const stdout = await new Response(proc.stdout).text();
203
+ const result = JSON.parse(stdout.trim()) as { pid?: number };
204
+ return result.pid ? { pid: result.pid } : null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ },
209
+ async stop(): Promise<boolean> {
210
+ const proc = Bun.spawn(["overstory", "monitor", "stop", "--json"], {
211
+ cwd: projectRoot,
212
+ stdout: "pipe",
213
+ stderr: "pipe",
214
+ });
215
+ const exitCode = await proc.exited;
216
+ return exitCode === 0;
217
+ },
218
+ async isRunning(): Promise<boolean> {
219
+ const proc = Bun.spawn(["overstory", "monitor", "status", "--json"], {
220
+ cwd: projectRoot,
221
+ stdout: "pipe",
222
+ stderr: "pipe",
223
+ });
224
+ const exitCode = await proc.exited;
225
+ if (exitCode !== 0) return false;
226
+ try {
227
+ const stdout = await new Response(proc.stdout).text();
228
+ const result = JSON.parse(stdout.trim()) as { running?: boolean };
229
+ return result.running === true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Build the coordinator startup beacon — the first message sent to the coordinator
239
+ * via tmux send-keys after Claude Code initializes.
240
+ *
241
+ * @param cliName - The tracker CLI name to use in startup instructions (default: "bd")
242
+ */
243
+ export function buildCoordinatorBeacon(cliName = "bd"): string {
244
+ const timestamp = new Date().toISOString();
245
+ const parts = [
246
+ `[OVERSTORY] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
247
+ "Depth: 0 | Parent: none | Role: persistent orchestrator",
248
+ "HIERARCHY: You ONLY spawn leads (overstory sling --capability lead). Leads spawn scouts, builders, reviewers. NEVER spawn non-lead agents directly.",
249
+ "DELEGATION: For any exploration/scouting, spawn a lead who will spawn scouts. Do NOT explore the codebase yourself beyond initial planning.",
250
+ `Startup: run mulch prime, check mail (overstory mail check --agent ${COORDINATOR_NAME}), check ${cliName} ready, check overstory group status, then begin work`,
251
+ ];
252
+ return parts.join(" — ");
253
+ }
254
+
255
+ /**
256
+ * Start the coordinator agent.
257
+ *
258
+ * 1. Verify no coordinator is already running
259
+ * 2. Load config
260
+ * 3. Create agent identity (if first time)
261
+ * 4. Deploy hooks to project root's .claude/settings.local.json
262
+ * 5. Spawn tmux session at project root with Claude Code
263
+ * 6. Send startup beacon
264
+ * 7. Record session in SessionStore (sessions.db)
265
+ */
266
+ /**
267
+ * Determine whether to auto-attach to the tmux session after starting.
268
+ * Exported for testing.
269
+ */
270
+ export function resolveAttach(args: string[], isTTY: boolean): boolean {
271
+ if (args.includes("--attach")) return true;
272
+ if (args.includes("--no-attach")) return false;
273
+ return isTTY;
274
+ }
275
+
276
+ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
277
+ const tmux = deps._tmux ?? {
278
+ createSession,
279
+ isSessionAlive,
280
+ killSession,
281
+ sendKeys,
282
+ waitForTuiReady,
283
+ };
284
+
285
+ const json = args.includes("--json");
286
+ const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
287
+ const watchdogFlag = args.includes("--watchdog");
288
+ const monitorFlag = args.includes("--monitor");
289
+
290
+ if (isRunningAsRoot()) {
291
+ throw new AgentError(
292
+ "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.",
293
+ );
294
+ }
295
+
296
+ const cwd = process.cwd();
297
+ const config = await loadConfig(cwd);
298
+ const projectRoot = config.project.root;
299
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
300
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
301
+ const tmuxSession = coordinatorTmuxSession(config.project.name);
302
+
303
+ // Check for existing coordinator
304
+ const overstoryDir = join(projectRoot, ".overstory");
305
+ const { store } = openSessionStore(overstoryDir);
306
+ try {
307
+ const existing = store.getByName(COORDINATOR_NAME);
308
+
309
+ if (
310
+ existing &&
311
+ existing.capability === "coordinator" &&
312
+ existing.state !== "completed" &&
313
+ existing.state !== "zombie"
314
+ ) {
315
+ const alive = await tmux.isSessionAlive(existing.tmuxSession);
316
+ if (alive) {
317
+ throw new AgentError(
318
+ `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
319
+ { agentName: COORDINATOR_NAME },
320
+ );
321
+ }
322
+ // Session recorded but tmux is dead — mark as completed and continue
323
+ store.updateState(COORDINATOR_NAME, "completed");
324
+ }
325
+
326
+ // Deploy hooks to the project root so the coordinator gets event logging,
327
+ // mail check --inject, and activity tracking via the standard hook pipeline.
328
+ // The ENV_GUARD prefix on all hooks (both template and generated guards)
329
+ // ensures they only activate when OVERSTORY_AGENT_NAME is set (i.e. for
330
+ // the coordinator's tmux session), so the user's own Claude Code session
331
+ // at the project root is unaffected.
332
+ await deployHooks(projectRoot, COORDINATOR_NAME, "coordinator");
333
+
334
+ // Create coordinator identity if first run
335
+ const identityBaseDir = join(projectRoot, ".overstory", "agents");
336
+ await mkdir(identityBaseDir, { recursive: true });
337
+ const existingIdentity = await loadIdentity(identityBaseDir, COORDINATOR_NAME);
338
+ if (!existingIdentity) {
339
+ await createIdentity(identityBaseDir, {
340
+ name: COORDINATOR_NAME,
341
+ capability: "coordinator",
342
+ created: new Date().toISOString(),
343
+ sessionsCompleted: 0,
344
+ expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
345
+ recentTasks: [],
346
+ });
347
+ }
348
+
349
+ // Resolve model from config > manifest > fallback
350
+ const manifestLoader = createManifestLoader(
351
+ join(projectRoot, config.agents.manifestPath),
352
+ join(projectRoot, config.agents.baseDir),
353
+ );
354
+ const manifest = await manifestLoader.load();
355
+ const { model, env } = resolveModel(config, manifest, "coordinator", "opus");
356
+
357
+ // Spawn tmux session at project root with Claude Code (interactive mode).
358
+ // Inject the coordinator base definition via --append-system-prompt so the
359
+ // coordinator knows its role, hierarchy rules, and delegation patterns
360
+ // (overstory-gaio, overstory-0kwf).
361
+ const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "coordinator.md");
362
+ const agentDefFile = Bun.file(agentDefPath);
363
+ let claudeCmd = `claude --model ${model} --dangerously-skip-permissions`;
364
+ if (await agentDefFile.exists()) {
365
+ const agentDef = await agentDefFile.text();
366
+ // Single-quote the content for safe shell expansion (only escape single quotes)
367
+ const escaped = agentDef.replace(/'/g, "'\\''");
368
+ claudeCmd += ` --append-system-prompt '${escaped}'`;
369
+ }
370
+ const pid = await tmux.createSession(tmuxSession, projectRoot, claudeCmd, {
371
+ ...env,
372
+ OVERSTORY_AGENT_NAME: COORDINATOR_NAME,
373
+ });
374
+
375
+ // Record session BEFORE sending the beacon so that hook-triggered
376
+ // updateLastActivity() can find the entry and transition booting->working.
377
+ // Without this, a race exists: hooks fire before the session is persisted,
378
+ // leaving the coordinator stuck in "booting" (overstory-036f).
379
+ const session: AgentSession = {
380
+ id: `session-${Date.now()}-${COORDINATOR_NAME}`,
381
+ agentName: COORDINATOR_NAME,
382
+ capability: "coordinator",
383
+ worktreePath: projectRoot, // Coordinator uses project root, not a worktree
384
+ branchName: config.project.canonicalBranch, // Operates on canonical branch
385
+ beadId: "", // No specific bead assignment
386
+ tmuxSession,
387
+ state: "booting",
388
+ pid,
389
+ parentAgent: null, // Top of hierarchy
390
+ depth: 0,
391
+ runId: null,
392
+ startedAt: new Date().toISOString(),
393
+ lastActivity: new Date().toISOString(),
394
+ escalationLevel: 0,
395
+ stalledSince: null,
396
+ };
397
+
398
+ store.upsert(session);
399
+
400
+ // Wait for Claude Code TUI to render before sending input
401
+ await tmux.waitForTuiReady(tmuxSession);
402
+ await Bun.sleep(1_000);
403
+
404
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
405
+ const trackerCli = trackerCliName(resolvedBackend);
406
+ const beacon = buildCoordinatorBeacon(trackerCli);
407
+ await tmux.sendKeys(tmuxSession, beacon);
408
+
409
+ // Follow-up Enters with increasing delays to ensure submission
410
+ for (const delay of [1_000, 2_000]) {
411
+ await Bun.sleep(delay);
412
+ await tmux.sendKeys(tmuxSession, "");
413
+ }
414
+
415
+ // Auto-start watchdog if --watchdog flag is present
416
+ let watchdogPid: number | undefined;
417
+ if (watchdogFlag) {
418
+ const watchdogResult = await watchdog.start();
419
+ if (watchdogResult) {
420
+ watchdogPid = watchdogResult.pid;
421
+ if (!json) process.stdout.write(` Watchdog: started (PID ${watchdogResult.pid})\n`);
422
+ } else {
423
+ if (!json) process.stderr.write(" Watchdog: failed to start or already running\n");
424
+ }
425
+ }
426
+
427
+ // Auto-start monitor if --monitor flag is present and tier2 is enabled
428
+ let monitorPid: number | undefined;
429
+ if (monitorFlag) {
430
+ if (!config.watchdog.tier2Enabled) {
431
+ if (!json)
432
+ process.stderr.write(" Monitor: skipped (watchdog.tier2Enabled is false in config)\n");
433
+ } else {
434
+ const monitorResult = await monitor.start([]);
435
+ if (monitorResult) {
436
+ monitorPid = monitorResult.pid;
437
+ if (!json) process.stdout.write(` Monitor: started (PID ${monitorResult.pid})\n`);
438
+ } else {
439
+ if (!json) process.stderr.write(" Monitor: failed to start or already running\n");
440
+ }
441
+ }
442
+ }
443
+
444
+ const output = {
445
+ agentName: COORDINATOR_NAME,
446
+ capability: "coordinator",
447
+ tmuxSession,
448
+ projectRoot,
449
+ pid,
450
+ watchdog: watchdogFlag ? watchdogPid !== undefined : false,
451
+ monitor: monitorFlag ? monitorPid !== undefined : false,
452
+ };
453
+
454
+ if (json) {
455
+ process.stdout.write(`${JSON.stringify(output)}\n`);
456
+ } else {
457
+ process.stdout.write("Coordinator started\n");
458
+ process.stdout.write(` Tmux: ${tmuxSession}\n`);
459
+ process.stdout.write(` Root: ${projectRoot}\n`);
460
+ process.stdout.write(` PID: ${pid}\n`);
461
+ }
462
+
463
+ if (shouldAttach) {
464
+ Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
465
+ stdio: ["inherit", "inherit", "inherit"],
466
+ });
467
+ }
468
+ } finally {
469
+ store.close();
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Stop the coordinator agent.
475
+ *
476
+ * 1. Find the active coordinator session
477
+ * 2. Kill the tmux session (with process tree cleanup)
478
+ * 3. Mark session as completed in SessionStore
479
+ * 4. Auto-complete the active run (if current-run.txt exists)
480
+ */
481
+ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
482
+ const tmux = deps._tmux ?? {
483
+ createSession,
484
+ isSessionAlive,
485
+ killSession,
486
+ sendKeys,
487
+ waitForTuiReady,
488
+ };
489
+
490
+ const json = args.includes("--json");
491
+ const cwd = process.cwd();
492
+ const config = await loadConfig(cwd);
493
+ const projectRoot = config.project.root;
494
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
495
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
496
+
497
+ const overstoryDir = join(projectRoot, ".overstory");
498
+ const { store } = openSessionStore(overstoryDir);
499
+ try {
500
+ const session = store.getByName(COORDINATOR_NAME);
501
+
502
+ if (
503
+ !session ||
504
+ session.capability !== "coordinator" ||
505
+ session.state === "completed" ||
506
+ session.state === "zombie"
507
+ ) {
508
+ throw new AgentError("No active coordinator session found", {
509
+ agentName: COORDINATOR_NAME,
510
+ });
511
+ }
512
+
513
+ // Kill tmux session with process tree cleanup
514
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
515
+ if (alive) {
516
+ await tmux.killSession(session.tmuxSession);
517
+ }
518
+
519
+ // Always attempt to stop watchdog
520
+ const watchdogStopped = await watchdog.stop();
521
+
522
+ // Always attempt to stop monitor
523
+ const monitorStopped = await monitor.stop();
524
+
525
+ // Update session state
526
+ store.updateState(COORDINATOR_NAME, "completed");
527
+ store.updateLastActivity(COORDINATOR_NAME);
528
+
529
+ // Auto-complete the current run
530
+ let runCompleted = false;
531
+ try {
532
+ const currentRunPath = join(overstoryDir, "current-run.txt");
533
+ const currentRunFile = Bun.file(currentRunPath);
534
+ if (await currentRunFile.exists()) {
535
+ const runId = (await currentRunFile.text()).trim();
536
+ if (runId.length > 0) {
537
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
538
+ try {
539
+ runStore.completeRun(runId, "completed");
540
+ runCompleted = true;
541
+ } finally {
542
+ runStore.close();
543
+ }
544
+ try {
545
+ await unlink(currentRunPath);
546
+ } catch {
547
+ // File may already be gone
548
+ }
549
+ }
550
+ }
551
+ } catch {
552
+ // Non-fatal: run completion should not break coordinator stop
553
+ }
554
+
555
+ if (json) {
556
+ process.stdout.write(
557
+ `${JSON.stringify({ stopped: true, sessionId: session.id, watchdogStopped, monitorStopped, runCompleted })}\n`,
558
+ );
559
+ } else {
560
+ process.stdout.write(`Coordinator stopped (session: ${session.id})\n`);
561
+ if (watchdogStopped) {
562
+ process.stdout.write("Watchdog stopped\n");
563
+ } else {
564
+ process.stdout.write("No watchdog running\n");
565
+ }
566
+ if (monitorStopped) {
567
+ process.stdout.write("Monitor stopped\n");
568
+ } else {
569
+ process.stdout.write("No monitor running\n");
570
+ }
571
+ if (runCompleted) {
572
+ process.stdout.write("Run completed\n");
573
+ } else {
574
+ process.stdout.write("No active run\n");
575
+ }
576
+ }
577
+ } finally {
578
+ store.close();
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Show coordinator status.
584
+ *
585
+ * Checks session registry and tmux liveness to report actual state.
586
+ */
587
+ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
588
+ const tmux = deps._tmux ?? {
589
+ createSession,
590
+ isSessionAlive,
591
+ killSession,
592
+ sendKeys,
593
+ waitForTuiReady,
594
+ };
595
+
596
+ const json = args.includes("--json");
597
+ const cwd = process.cwd();
598
+ const config = await loadConfig(cwd);
599
+ const projectRoot = config.project.root;
600
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
601
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
602
+
603
+ const overstoryDir = join(projectRoot, ".overstory");
604
+ const { store } = openSessionStore(overstoryDir);
605
+ try {
606
+ const session = store.getByName(COORDINATOR_NAME);
607
+ const watchdogRunning = await watchdog.isRunning();
608
+ const monitorRunning = await monitor.isRunning();
609
+
610
+ if (
611
+ !session ||
612
+ session.capability !== "coordinator" ||
613
+ session.state === "completed" ||
614
+ session.state === "zombie"
615
+ ) {
616
+ if (json) {
617
+ process.stdout.write(
618
+ `${JSON.stringify({ running: false, watchdogRunning, monitorRunning })}\n`,
619
+ );
620
+ } else {
621
+ process.stdout.write("Coordinator is not running\n");
622
+ if (watchdogRunning) {
623
+ process.stdout.write("Watchdog: running\n");
624
+ }
625
+ if (monitorRunning) {
626
+ process.stdout.write("Monitor: running\n");
627
+ }
628
+ }
629
+ return;
630
+ }
631
+
632
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
633
+
634
+ // Reconcile state: if session says active but tmux is dead, update.
635
+ // We already filtered out completed/zombie states above, so if tmux is dead
636
+ // this session needs to be marked as zombie.
637
+ if (!alive) {
638
+ store.updateState(COORDINATOR_NAME, "zombie");
639
+ store.updateLastActivity(COORDINATOR_NAME);
640
+ session.state = "zombie";
641
+ }
642
+
643
+ const status = {
644
+ running: alive,
645
+ sessionId: session.id,
646
+ state: session.state,
647
+ tmuxSession: session.tmuxSession,
648
+ pid: session.pid,
649
+ startedAt: session.startedAt,
650
+ lastActivity: session.lastActivity,
651
+ watchdogRunning,
652
+ monitorRunning,
653
+ };
654
+
655
+ if (json) {
656
+ process.stdout.write(`${JSON.stringify(status)}\n`);
657
+ } else {
658
+ const stateLabel = alive ? "running" : session.state;
659
+ process.stdout.write(`Coordinator: ${stateLabel}\n`);
660
+ process.stdout.write(` Session: ${session.id}\n`);
661
+ process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
662
+ process.stdout.write(` PID: ${session.pid}\n`);
663
+ process.stdout.write(` Started: ${session.startedAt}\n`);
664
+ process.stdout.write(` Activity: ${session.lastActivity}\n`);
665
+ process.stdout.write(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
666
+ process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
667
+ }
668
+ } finally {
669
+ store.close();
670
+ }
671
+ }
672
+
673
+ const COORDINATOR_HELP = `overstory coordinator — Manage the persistent coordinator agent
674
+
675
+ Usage: overstory coordinator <subcommand> [flags]
676
+
677
+ Subcommands:
678
+ start Start the coordinator (spawns Claude Code at project root)
679
+ stop Stop the coordinator (kills tmux session)
680
+ status Show coordinator state
681
+
682
+ Start options:
683
+ --attach Always attach to tmux session after start
684
+ --no-attach Never attach to tmux session after start
685
+ Default: attach when running in an interactive TTY
686
+ --watchdog Auto-start watchdog daemon with coordinator
687
+ --monitor Auto-start monitor agent (Tier 2) with coordinator
688
+
689
+ General options:
690
+ --json Output as JSON
691
+ --help, -h Show this help
692
+
693
+ The coordinator runs at the project root and orchestrates work by:
694
+ - Decomposing objectives into beads issues
695
+ - Dispatching agents via overstory sling
696
+ - Tracking batches via task groups
697
+ - Handling escalations from agents and watchdog`;
698
+
699
+ /**
700
+ * Entry point for `overstory coordinator <subcommand>`.
701
+ *
702
+ * @param args - CLI arguments after "coordinator"
703
+ * @param deps - Optional dependency injection for testing (tmux)
704
+ */
705
+ export async function coordinatorCommand(
706
+ args: string[],
707
+ deps: CoordinatorDeps = {},
708
+ ): Promise<void> {
709
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
710
+ process.stdout.write(`${COORDINATOR_HELP}\n`);
711
+ return;
712
+ }
713
+
714
+ const subcommand = args[0];
715
+ const subArgs = args.slice(1);
716
+
717
+ switch (subcommand) {
718
+ case "start":
719
+ await startCoordinator(subArgs, deps);
720
+ break;
721
+ case "stop":
722
+ await stopCoordinator(subArgs, deps);
723
+ break;
724
+ case "status":
725
+ await statusCoordinator(subArgs, deps);
726
+ break;
727
+ default:
728
+ throw new ValidationError(
729
+ `Unknown coordinator subcommand: ${subcommand}. Run 'overstory coordinator --help' for usage.`,
730
+ { field: "subcommand", value: subcommand },
731
+ );
732
+ }
733
+ }