@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|