@katyella/legio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -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 +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio 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 legio 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 { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { access, mkdir, readFile, unlink, writeFile } 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 { collectProviderEnv, loadConfig } from "../config.ts";
|
|
22
|
+
import { AgentError, isRunningAsRoot, ValidationError } from "../errors.ts";
|
|
23
|
+
import { HeadlessCoordinator } from "../server/headless.ts";
|
|
24
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
25
|
+
import { createRunStore } from "../sessions/store.ts";
|
|
26
|
+
import type { AgentSession, HeadlessCoordinatorConfig } from "../types.ts";
|
|
27
|
+
import { isProcessRunning } from "../watchdog/health.ts";
|
|
28
|
+
import {
|
|
29
|
+
createSession,
|
|
30
|
+
isSessionAlive,
|
|
31
|
+
killSession,
|
|
32
|
+
sendKeys,
|
|
33
|
+
waitForTuiReady,
|
|
34
|
+
} from "../worktree/tmux.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 (legio-pcef).
|
|
42
|
+
*/
|
|
43
|
+
function coordinatorTmuxSession(projectName: string): string {
|
|
44
|
+
return `legio-${projectName}-${COORDINATOR_NAME}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run a subprocess and collect its output. Returns exit code, stdout, and stderr.
|
|
49
|
+
*/
|
|
50
|
+
function runProcess(
|
|
51
|
+
cmd: string[],
|
|
52
|
+
opts: { cwd?: string } = {},
|
|
53
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const [command, ...args] = cmd;
|
|
56
|
+
if (!command) {
|
|
57
|
+
reject(new Error("Empty command array"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const proc = spawn(command, args, {
|
|
61
|
+
cwd: opts.cwd,
|
|
62
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
});
|
|
64
|
+
let stdout = "";
|
|
65
|
+
let stderr = "";
|
|
66
|
+
proc.stdout.on("data", (d: Buffer) => {
|
|
67
|
+
stdout += d.toString();
|
|
68
|
+
});
|
|
69
|
+
proc.stderr.on("data", (d: Buffer) => {
|
|
70
|
+
stderr += d.toString();
|
|
71
|
+
});
|
|
72
|
+
proc.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
|
|
73
|
+
proc.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a file exists at the given path.
|
|
79
|
+
*/
|
|
80
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await access(path);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Minimal interface required from a HeadlessCoordinator for DI testing. */
|
|
90
|
+
export interface HeadlessCoordinatorHandle {
|
|
91
|
+
start(): void;
|
|
92
|
+
write(input: string): void;
|
|
93
|
+
stop(): Promise<void>;
|
|
94
|
+
getPid(): number | null;
|
|
95
|
+
isRunning(): boolean;
|
|
96
|
+
on(event: "exit", listener: (code: number) => void): this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Dependency injection for testing. Uses real implementations when omitted. */
|
|
100
|
+
export interface CoordinatorDeps {
|
|
101
|
+
_tmux?: {
|
|
102
|
+
createSession: (
|
|
103
|
+
name: string,
|
|
104
|
+
cwd: string,
|
|
105
|
+
command: string,
|
|
106
|
+
env?: Record<string, string>,
|
|
107
|
+
) => Promise<number>;
|
|
108
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
109
|
+
killSession: (name: string) => Promise<void>;
|
|
110
|
+
sendKeys: (name: string, keys: string) => Promise<void>;
|
|
111
|
+
waitForTuiReady?: (
|
|
112
|
+
sessionName: string,
|
|
113
|
+
opts?: { timeout?: number; interval?: number },
|
|
114
|
+
) => Promise<void>;
|
|
115
|
+
};
|
|
116
|
+
_watchdog?: {
|
|
117
|
+
start: () => Promise<{ pid: number } | null>;
|
|
118
|
+
stop: () => Promise<boolean>;
|
|
119
|
+
isRunning: () => Promise<boolean>;
|
|
120
|
+
};
|
|
121
|
+
_monitor?: {
|
|
122
|
+
start: (args: string[]) => Promise<{ pid: number } | null>;
|
|
123
|
+
stop: () => Promise<boolean>;
|
|
124
|
+
isRunning: () => Promise<boolean>;
|
|
125
|
+
};
|
|
126
|
+
_headless?: {
|
|
127
|
+
create: (config: HeadlessCoordinatorConfig) => HeadlessCoordinatorHandle;
|
|
128
|
+
};
|
|
129
|
+
_sleep?: (ms: number) => Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the PID from the watchdog PID file.
|
|
134
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
135
|
+
*/
|
|
136
|
+
async function readWatchdogPid(projectRoot: string): Promise<number | null> {
|
|
137
|
+
const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
|
|
138
|
+
if (!(await fileExists(pidFilePath))) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const text = await readFile(pidFilePath, "utf-8");
|
|
144
|
+
const pid = Number.parseInt(text.trim(), 10);
|
|
145
|
+
if (Number.isNaN(pid) || pid <= 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return pid;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Remove the watchdog PID file.
|
|
156
|
+
*/
|
|
157
|
+
async function removeWatchdogPid(projectRoot: string): Promise<void> {
|
|
158
|
+
const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
|
|
159
|
+
try {
|
|
160
|
+
await unlink(pidFilePath);
|
|
161
|
+
} catch {
|
|
162
|
+
// File may already be gone — not an error
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Default watchdog implementation for production use.
|
|
168
|
+
* Starts/stops the watchdog daemon via `legio watch --background`.
|
|
169
|
+
*/
|
|
170
|
+
function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
|
|
171
|
+
return {
|
|
172
|
+
async start(): Promise<{ pid: number } | null> {
|
|
173
|
+
// Check if watchdog is already running
|
|
174
|
+
const existingPid = await readWatchdogPid(projectRoot);
|
|
175
|
+
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
176
|
+
return null; // Already running
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Clean up stale PID file
|
|
180
|
+
if (existingPid !== null) {
|
|
181
|
+
await removeWatchdogPid(projectRoot);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Start watchdog in background
|
|
185
|
+
const { exitCode } = await runProcess(["legio", "watch", "--background"], {
|
|
186
|
+
cwd: projectRoot,
|
|
187
|
+
});
|
|
188
|
+
if (exitCode !== 0) {
|
|
189
|
+
return null; // Failed to start
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Read the PID file that was written by the background process
|
|
193
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
194
|
+
if (pid === null) {
|
|
195
|
+
return null; // PID file wasn't created
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { pid };
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async stop(): Promise<boolean> {
|
|
202
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
203
|
+
if (pid === null) {
|
|
204
|
+
return false; // No PID file
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if process is running
|
|
208
|
+
if (!isProcessRunning(pid)) {
|
|
209
|
+
// Process is dead, clean up PID file
|
|
210
|
+
await removeWatchdogPid(projectRoot);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Kill the process
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, 15); // SIGTERM
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Remove PID file
|
|
222
|
+
await removeWatchdogPid(projectRoot);
|
|
223
|
+
return true;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async isRunning(): Promise<boolean> {
|
|
227
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
228
|
+
if (pid === null) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return isProcessRunning(pid);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Default monitor implementation for production use.
|
|
238
|
+
* Starts/stops the monitor agent via `legio monitor start/stop`.
|
|
239
|
+
*/
|
|
240
|
+
function createDefaultMonitor(projectRoot: string): NonNullable<CoordinatorDeps["_monitor"]> {
|
|
241
|
+
return {
|
|
242
|
+
async start(): Promise<{ pid: number } | null> {
|
|
243
|
+
const { exitCode, stdout } = await runProcess(
|
|
244
|
+
["legio", "monitor", "start", "--no-attach", "--json"],
|
|
245
|
+
{ cwd: projectRoot },
|
|
246
|
+
);
|
|
247
|
+
if (exitCode !== 0) return null;
|
|
248
|
+
try {
|
|
249
|
+
const result = JSON.parse(stdout.trim()) as { pid?: number };
|
|
250
|
+
return result.pid ? { pid: result.pid } : null;
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async stop(): Promise<boolean> {
|
|
256
|
+
const { exitCode } = await runProcess(["legio", "monitor", "stop", "--json"], {
|
|
257
|
+
cwd: projectRoot,
|
|
258
|
+
});
|
|
259
|
+
return exitCode === 0;
|
|
260
|
+
},
|
|
261
|
+
async isRunning(): Promise<boolean> {
|
|
262
|
+
const { exitCode, stdout } = await runProcess(["legio", "monitor", "status", "--json"], {
|
|
263
|
+
cwd: projectRoot,
|
|
264
|
+
});
|
|
265
|
+
if (exitCode !== 0) return false;
|
|
266
|
+
try {
|
|
267
|
+
const result = JSON.parse(stdout.trim()) as { running?: boolean };
|
|
268
|
+
return result.running === true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build the coordinator startup beacon — the first message sent to the coordinator
|
|
278
|
+
* via tmux send-keys after Claude Code initializes.
|
|
279
|
+
*/
|
|
280
|
+
export function buildCoordinatorBeacon(): string {
|
|
281
|
+
const timestamp = new Date().toISOString();
|
|
282
|
+
const parts = [
|
|
283
|
+
`[LEGIO] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
|
|
284
|
+
"Depth: 0 | Parent: none | Role: persistent orchestrator",
|
|
285
|
+
"HIERARCHY: You ONLY spawn leads (legio sling --capability lead). Leads spawn scouts, builders, reviewers. NEVER spawn non-lead agents directly.",
|
|
286
|
+
"DELEGATION: For any exploration/scouting, spawn a lead who will spawn scouts. Do NOT explore the codebase yourself beyond initial planning.",
|
|
287
|
+
`Startup: run mulch prime, check mail (legio mail check --agent ${COORDINATOR_NAME}), check bd ready, check legio group status, then begin work`,
|
|
288
|
+
];
|
|
289
|
+
return parts.join(" — ");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Start the coordinator agent.
|
|
294
|
+
*
|
|
295
|
+
* 1. Verify no coordinator is already running
|
|
296
|
+
* 2. Load config
|
|
297
|
+
* 3. Create agent identity (if first time)
|
|
298
|
+
* 4. Deploy hooks to project root's .claude/settings.local.json
|
|
299
|
+
* 5. Spawn tmux session at project root with Claude Code
|
|
300
|
+
* 6. Send startup beacon
|
|
301
|
+
* 7. Record session in SessionStore (sessions.db)
|
|
302
|
+
*/
|
|
303
|
+
/**
|
|
304
|
+
* Determine whether to auto-attach to the tmux session after starting.
|
|
305
|
+
* Exported for testing.
|
|
306
|
+
*/
|
|
307
|
+
export function resolveAttach(args: string[], isTTY: boolean): boolean {
|
|
308
|
+
if (args.includes("--attach")) return true;
|
|
309
|
+
if (args.includes("--no-attach")) return false;
|
|
310
|
+
return isTTY;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
|
|
314
|
+
const tmux = deps._tmux ?? {
|
|
315
|
+
createSession,
|
|
316
|
+
isSessionAlive,
|
|
317
|
+
killSession,
|
|
318
|
+
sendKeys,
|
|
319
|
+
waitForTuiReady,
|
|
320
|
+
};
|
|
321
|
+
const sleep =
|
|
322
|
+
deps._sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
323
|
+
|
|
324
|
+
if (isRunningAsRoot()) {
|
|
325
|
+
throw new ValidationError(
|
|
326
|
+
"legio must not run as root — agent processes execute arbitrary code",
|
|
327
|
+
{
|
|
328
|
+
field: "uid",
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const json = args.includes("--json");
|
|
334
|
+
const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
|
|
335
|
+
const watchdogFlag = args.includes("--watchdog");
|
|
336
|
+
const monitorFlag = args.includes("--monitor");
|
|
337
|
+
const headlessFlag = args.includes("--headless");
|
|
338
|
+
const cwd = process.cwd();
|
|
339
|
+
const config = await loadConfig(cwd);
|
|
340
|
+
const projectRoot = config.project.root;
|
|
341
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
342
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
343
|
+
const tmuxSession = coordinatorTmuxSession(config.project.name);
|
|
344
|
+
|
|
345
|
+
// Check for existing coordinator
|
|
346
|
+
const legioDir = join(projectRoot, ".legio");
|
|
347
|
+
const { store } = openSessionStore(legioDir);
|
|
348
|
+
try {
|
|
349
|
+
const existing = store.getByName(COORDINATOR_NAME);
|
|
350
|
+
|
|
351
|
+
if (
|
|
352
|
+
existing &&
|
|
353
|
+
existing.capability === "coordinator" &&
|
|
354
|
+
existing.state !== "completed" &&
|
|
355
|
+
existing.state !== "zombie"
|
|
356
|
+
) {
|
|
357
|
+
const alive = await tmux.isSessionAlive(existing.tmuxSession);
|
|
358
|
+
if (alive) {
|
|
359
|
+
throw new AgentError(
|
|
360
|
+
`Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
|
|
361
|
+
{ agentName: COORDINATOR_NAME },
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
// Session recorded but tmux is dead — mark as completed and continue
|
|
365
|
+
store.updateState(COORDINATOR_NAME, "completed");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Deploy hooks to the project root so the coordinator gets event logging,
|
|
369
|
+
// mail check --inject, and activity tracking via the standard hook pipeline.
|
|
370
|
+
// The ENV_GUARD prefix on all hooks (both template and generated guards)
|
|
371
|
+
// ensures they only activate when LEGIO_AGENT_NAME is set (i.e. for
|
|
372
|
+
// the coordinator's tmux session), so the user's own Claude Code session
|
|
373
|
+
// at the project root is unaffected.
|
|
374
|
+
await deployHooks(projectRoot, COORDINATOR_NAME, "coordinator");
|
|
375
|
+
|
|
376
|
+
// Create coordinator identity if first run
|
|
377
|
+
const identityBaseDir = join(projectRoot, ".legio", "agents");
|
|
378
|
+
await mkdir(identityBaseDir, { recursive: true });
|
|
379
|
+
const existingIdentity = await loadIdentity(identityBaseDir, COORDINATOR_NAME);
|
|
380
|
+
if (!existingIdentity) {
|
|
381
|
+
await createIdentity(identityBaseDir, {
|
|
382
|
+
name: COORDINATOR_NAME,
|
|
383
|
+
capability: "coordinator",
|
|
384
|
+
created: new Date().toISOString(),
|
|
385
|
+
sessionsCompleted: 0,
|
|
386
|
+
expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
|
|
387
|
+
recentTasks: [],
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Resolve model from config > manifest > fallback
|
|
392
|
+
const manifestLoader = createManifestLoader(
|
|
393
|
+
join(projectRoot, config.agents.manifestPath),
|
|
394
|
+
join(projectRoot, config.agents.baseDir),
|
|
395
|
+
);
|
|
396
|
+
const manifest = await manifestLoader.load();
|
|
397
|
+
const model = resolveModel(config, manifest, "coordinator", "opus");
|
|
398
|
+
|
|
399
|
+
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
400
|
+
// Inject the coordinator base definition via --append-system-prompt so the
|
|
401
|
+
// coordinator knows its role, hierarchy rules, and delegation patterns
|
|
402
|
+
// (legio-gaio, legio-0kwf).
|
|
403
|
+
const agentDefPath = join(projectRoot, ".legio", "agent-defs", "coordinator.md");
|
|
404
|
+
// Build a settings JSON file that: (1) skips the bypass-permissions
|
|
405
|
+
// confirmation dialog, and (2) injects the agent definition as a system
|
|
406
|
+
// prompt suffix. Using --settings with a file avoids Claude Code's
|
|
407
|
+
// ERR_STREAM_DESTROYED crash when --append-system-prompt receives large
|
|
408
|
+
// inline payloads (14KB+).
|
|
409
|
+
const settings: Record<string, unknown> = { skipDangerousModePermissionPrompt: true };
|
|
410
|
+
if (await fileExists(agentDefPath)) {
|
|
411
|
+
settings.appendSystemPrompt = await readFile(agentDefPath, "utf-8");
|
|
412
|
+
}
|
|
413
|
+
const settingsPath = join(legioDir, `settings-${COORDINATOR_NAME}.json`);
|
|
414
|
+
await writeFile(settingsPath, JSON.stringify(settings), "utf-8");
|
|
415
|
+
const claudeCmd = `claude --model ${model} --dangerously-skip-permissions --settings ${settingsPath}`;
|
|
416
|
+
|
|
417
|
+
if (headlessFlag) {
|
|
418
|
+
// ----------------------------------------------------------------
|
|
419
|
+
// Headless path: spawn via HeadlessCoordinator (no tmux)
|
|
420
|
+
// ----------------------------------------------------------------
|
|
421
|
+
const headlessFactory = deps._headless ?? {
|
|
422
|
+
create: (cfg: HeadlessCoordinatorConfig) => new HeadlessCoordinator(cfg),
|
|
423
|
+
};
|
|
424
|
+
const headless = headlessFactory.create({
|
|
425
|
+
command: claudeCmd,
|
|
426
|
+
cwd: projectRoot,
|
|
427
|
+
env: { ...collectProviderEnv(), LEGIO_AGENT_NAME: COORDINATOR_NAME },
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
headless.start();
|
|
431
|
+
const headlessPid = headless.getPid();
|
|
432
|
+
|
|
433
|
+
// Write PID file for later stop/status operations
|
|
434
|
+
if (headlessPid !== null) {
|
|
435
|
+
const pidFilePath = join(legioDir, "headless-coordinator.pid");
|
|
436
|
+
await writeFile(pidFilePath, String(headlessPid), "utf-8");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Record session with tmuxSession="headless" to distinguish from tmux sessions
|
|
440
|
+
const headlessSession: AgentSession = {
|
|
441
|
+
id: `session-${Date.now()}-${COORDINATOR_NAME}`,
|
|
442
|
+
agentName: COORDINATOR_NAME,
|
|
443
|
+
capability: "coordinator",
|
|
444
|
+
worktreePath: projectRoot,
|
|
445
|
+
branchName: config.project.canonicalBranch,
|
|
446
|
+
beadId: "",
|
|
447
|
+
tmuxSession: "headless",
|
|
448
|
+
state: "booting",
|
|
449
|
+
pid: headlessPid,
|
|
450
|
+
parentAgent: null,
|
|
451
|
+
depth: 0,
|
|
452
|
+
runId: null,
|
|
453
|
+
startedAt: new Date().toISOString(),
|
|
454
|
+
lastActivity: new Date().toISOString(),
|
|
455
|
+
escalationLevel: 0,
|
|
456
|
+
stalledSince: null,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
store.upsert(headlessSession);
|
|
460
|
+
|
|
461
|
+
// Send startup beacon via stdin after initialization delay
|
|
462
|
+
await sleep(3_000);
|
|
463
|
+
const headlessBeacon = buildCoordinatorBeacon();
|
|
464
|
+
try {
|
|
465
|
+
headless.write(`${headlessBeacon}\n`);
|
|
466
|
+
} catch {
|
|
467
|
+
// stdin may not be available if process exited early
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const headlessOutput = {
|
|
471
|
+
agentName: COORDINATOR_NAME,
|
|
472
|
+
capability: "coordinator",
|
|
473
|
+
headless: true,
|
|
474
|
+
projectRoot,
|
|
475
|
+
pid: headlessPid,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (json) {
|
|
479
|
+
process.stdout.write(`${JSON.stringify(headlessOutput)}\n`);
|
|
480
|
+
} else {
|
|
481
|
+
process.stdout.write("Coordinator started (headless)\n");
|
|
482
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
483
|
+
process.stdout.write(` PID: ${headlessPid ?? "unknown"}\n`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Keep process alive until the headless coordinator exits
|
|
487
|
+
await new Promise<void>((resolve) => {
|
|
488
|
+
headless.on("exit", () => resolve());
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ----------------------------------------------------------------
|
|
495
|
+
// Tmux path: existing behavior
|
|
496
|
+
// ----------------------------------------------------------------
|
|
497
|
+
const pid = await tmux.createSession(tmuxSession, projectRoot, claudeCmd, {
|
|
498
|
+
...collectProviderEnv(),
|
|
499
|
+
LEGIO_AGENT_NAME: COORDINATOR_NAME,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Record session BEFORE sending the beacon so that hook-triggered
|
|
503
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
504
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
505
|
+
// leaving the coordinator stuck in "booting" (legio-036f).
|
|
506
|
+
const session: AgentSession = {
|
|
507
|
+
id: `session-${Date.now()}-${COORDINATOR_NAME}`,
|
|
508
|
+
agentName: COORDINATOR_NAME,
|
|
509
|
+
capability: "coordinator",
|
|
510
|
+
worktreePath: projectRoot, // Coordinator uses project root, not a worktree
|
|
511
|
+
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
512
|
+
beadId: "", // No specific bead assignment
|
|
513
|
+
tmuxSession,
|
|
514
|
+
state: "booting",
|
|
515
|
+
pid,
|
|
516
|
+
parentAgent: null, // Top of hierarchy
|
|
517
|
+
depth: 0,
|
|
518
|
+
runId: null,
|
|
519
|
+
startedAt: new Date().toISOString(),
|
|
520
|
+
lastActivity: new Date().toISOString(),
|
|
521
|
+
escalationLevel: 0,
|
|
522
|
+
stalledSince: null,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
store.upsert(session);
|
|
526
|
+
|
|
527
|
+
// Write output BEFORE the blocking sleep+sendKeys so that callers
|
|
528
|
+
// reading stdout (e.g., runLegio in the server) get the response
|
|
529
|
+
// immediately and don't hang waiting for the pipe to close.
|
|
530
|
+
const output = {
|
|
531
|
+
agentName: COORDINATOR_NAME,
|
|
532
|
+
capability: "coordinator",
|
|
533
|
+
tmuxSession,
|
|
534
|
+
projectRoot,
|
|
535
|
+
pid,
|
|
536
|
+
watchdog: false,
|
|
537
|
+
monitor: false,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
if (json) {
|
|
541
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
542
|
+
} else {
|
|
543
|
+
process.stdout.write("Coordinator started\n");
|
|
544
|
+
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
545
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
546
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Wait for Claude Code's TUI to render before sending beacon.
|
|
550
|
+
// Falls back to sleep(3_000) when waitForTuiReady is not in the DI mock.
|
|
551
|
+
if (tmux.waitForTuiReady) {
|
|
552
|
+
await tmux.waitForTuiReady(tmuxSession);
|
|
553
|
+
} else {
|
|
554
|
+
await sleep(3_000);
|
|
555
|
+
}
|
|
556
|
+
const beacon = buildCoordinatorBeacon();
|
|
557
|
+
await tmux.sendKeys(tmuxSession, beacon);
|
|
558
|
+
|
|
559
|
+
// Follow-up Enter to ensure submission (same pattern as sling.ts)
|
|
560
|
+
await sleep(500);
|
|
561
|
+
await tmux.sendKeys(tmuxSession, "");
|
|
562
|
+
|
|
563
|
+
// Auto-start watchdog if --watchdog flag is present
|
|
564
|
+
if (watchdogFlag) {
|
|
565
|
+
const watchdogResult = await watchdog.start();
|
|
566
|
+
if (watchdogResult) {
|
|
567
|
+
if (!json) process.stdout.write(` Watchdog: started (PID ${watchdogResult.pid})\n`);
|
|
568
|
+
} else {
|
|
569
|
+
if (!json) process.stderr.write(" Watchdog: failed to start or already running\n");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Auto-start monitor if --monitor flag is present
|
|
574
|
+
if (monitorFlag) {
|
|
575
|
+
const monitorResult = await monitor.start([]);
|
|
576
|
+
if (monitorResult) {
|
|
577
|
+
if (!json) process.stdout.write(` Monitor: started (PID ${monitorResult.pid})\n`);
|
|
578
|
+
} else {
|
|
579
|
+
if (!json) process.stderr.write(" Monitor: failed to start or already running\n");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (shouldAttach) {
|
|
584
|
+
spawnSync("tmux", ["attach-session", "-t", tmuxSession], {
|
|
585
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
} finally {
|
|
589
|
+
store.close();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Stop the coordinator agent.
|
|
595
|
+
*
|
|
596
|
+
* 1. Find the active coordinator session
|
|
597
|
+
* 2. Kill the tmux session (with process tree cleanup)
|
|
598
|
+
* 3. Mark session as completed in SessionStore
|
|
599
|
+
* 4. Auto-complete the active run (if current-run.txt exists)
|
|
600
|
+
*/
|
|
601
|
+
async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
|
|
602
|
+
const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
|
|
603
|
+
|
|
604
|
+
const json = args.includes("--json");
|
|
605
|
+
const cwd = process.cwd();
|
|
606
|
+
const config = await loadConfig(cwd);
|
|
607
|
+
const projectRoot = config.project.root;
|
|
608
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
609
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
610
|
+
|
|
611
|
+
const legioDir = join(projectRoot, ".legio");
|
|
612
|
+
const { store } = openSessionStore(legioDir);
|
|
613
|
+
try {
|
|
614
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
615
|
+
|
|
616
|
+
if (
|
|
617
|
+
!session ||
|
|
618
|
+
session.capability !== "coordinator" ||
|
|
619
|
+
session.state === "completed" ||
|
|
620
|
+
session.state === "zombie"
|
|
621
|
+
) {
|
|
622
|
+
throw new AgentError("No active coordinator session found", {
|
|
623
|
+
agentName: COORDINATOR_NAME,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (session.tmuxSession === "headless") {
|
|
628
|
+
// ----------------------------------------------------------------
|
|
629
|
+
// Headless stop: read PID from file and kill the process
|
|
630
|
+
// ----------------------------------------------------------------
|
|
631
|
+
const pidFilePath = join(legioDir, "headless-coordinator.pid");
|
|
632
|
+
let headlessPid: number | null = null;
|
|
633
|
+
try {
|
|
634
|
+
const pidText = await readFile(pidFilePath, "utf-8");
|
|
635
|
+
const parsed = Number.parseInt(pidText.trim(), 10);
|
|
636
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
637
|
+
headlessPid = parsed;
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// PID file may not exist — process may already be dead
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (headlessPid !== null && isProcessRunning(headlessPid)) {
|
|
644
|
+
try {
|
|
645
|
+
process.kill(headlessPid, 15); // SIGTERM
|
|
646
|
+
} catch {
|
|
647
|
+
// ignore — process may have already exited
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Clean up PID file
|
|
652
|
+
try {
|
|
653
|
+
await unlink(pidFilePath);
|
|
654
|
+
} catch {
|
|
655
|
+
// File may already be gone
|
|
656
|
+
}
|
|
657
|
+
} else {
|
|
658
|
+
// Kill tmux session with process tree cleanup
|
|
659
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
660
|
+
if (alive) {
|
|
661
|
+
await tmux.killSession(session.tmuxSession);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Always attempt to stop watchdog
|
|
666
|
+
const watchdogStopped = await watchdog.stop();
|
|
667
|
+
|
|
668
|
+
// Always attempt to stop monitor
|
|
669
|
+
const monitorStopped = await monitor.stop();
|
|
670
|
+
|
|
671
|
+
// Update session state
|
|
672
|
+
store.updateState(COORDINATOR_NAME, "completed");
|
|
673
|
+
store.updateLastActivity(COORDINATOR_NAME);
|
|
674
|
+
|
|
675
|
+
// Auto-complete the current run
|
|
676
|
+
let runCompleted = false;
|
|
677
|
+
try {
|
|
678
|
+
const currentRunPath = join(legioDir, "current-run.txt");
|
|
679
|
+
if (await fileExists(currentRunPath)) {
|
|
680
|
+
const runId = (await readFile(currentRunPath, "utf-8")).trim();
|
|
681
|
+
if (runId.length > 0) {
|
|
682
|
+
const runStore = createRunStore(join(legioDir, "sessions.db"));
|
|
683
|
+
try {
|
|
684
|
+
runStore.completeRun(runId, "completed");
|
|
685
|
+
runCompleted = true;
|
|
686
|
+
} finally {
|
|
687
|
+
runStore.close();
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
await unlink(currentRunPath);
|
|
691
|
+
} catch {
|
|
692
|
+
// File may already be gone
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// Non-fatal: run completion should not break coordinator stop
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (json) {
|
|
701
|
+
process.stdout.write(
|
|
702
|
+
`${JSON.stringify({ stopped: true, sessionId: session.id, watchdogStopped, monitorStopped, runCompleted })}\n`,
|
|
703
|
+
);
|
|
704
|
+
} else {
|
|
705
|
+
process.stdout.write(`Coordinator stopped (session: ${session.id})\n`);
|
|
706
|
+
if (watchdogStopped) {
|
|
707
|
+
process.stdout.write("Watchdog stopped\n");
|
|
708
|
+
} else {
|
|
709
|
+
process.stdout.write("No watchdog running\n");
|
|
710
|
+
}
|
|
711
|
+
if (monitorStopped) {
|
|
712
|
+
process.stdout.write("Monitor stopped\n");
|
|
713
|
+
} else {
|
|
714
|
+
process.stdout.write("No monitor running\n");
|
|
715
|
+
}
|
|
716
|
+
if (runCompleted) {
|
|
717
|
+
process.stdout.write("Run completed\n");
|
|
718
|
+
} else {
|
|
719
|
+
process.stdout.write("No active run\n");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} finally {
|
|
723
|
+
store.close();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Show coordinator status.
|
|
729
|
+
*
|
|
730
|
+
* Checks session registry and tmux liveness to report actual state.
|
|
731
|
+
*/
|
|
732
|
+
async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
|
|
733
|
+
const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
|
|
734
|
+
|
|
735
|
+
const json = args.includes("--json");
|
|
736
|
+
const cwd = process.cwd();
|
|
737
|
+
const config = await loadConfig(cwd);
|
|
738
|
+
const projectRoot = config.project.root;
|
|
739
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
740
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
741
|
+
|
|
742
|
+
const legioDir = join(projectRoot, ".legio");
|
|
743
|
+
const { store } = openSessionStore(legioDir);
|
|
744
|
+
try {
|
|
745
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
746
|
+
const watchdogRunning = await watchdog.isRunning();
|
|
747
|
+
const monitorRunning = await monitor.isRunning();
|
|
748
|
+
|
|
749
|
+
if (
|
|
750
|
+
!session ||
|
|
751
|
+
session.capability !== "coordinator" ||
|
|
752
|
+
session.state === "completed" ||
|
|
753
|
+
session.state === "zombie"
|
|
754
|
+
) {
|
|
755
|
+
if (json) {
|
|
756
|
+
process.stdout.write(
|
|
757
|
+
`${JSON.stringify({ running: false, watchdogRunning, monitorRunning })}\n`,
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
process.stdout.write("Coordinator is not running\n");
|
|
761
|
+
if (watchdogRunning) {
|
|
762
|
+
process.stdout.write("Watchdog: running\n");
|
|
763
|
+
}
|
|
764
|
+
if (monitorRunning) {
|
|
765
|
+
process.stdout.write("Monitor: running\n");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const isHeadless = session.tmuxSession === "headless";
|
|
772
|
+
|
|
773
|
+
// For headless sessions, liveness is determined by PID; for tmux sessions, by session.
|
|
774
|
+
let alive: boolean;
|
|
775
|
+
if (isHeadless) {
|
|
776
|
+
alive = session.pid !== null && isProcessRunning(session.pid);
|
|
777
|
+
} else {
|
|
778
|
+
alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Reconcile state: if session says active but process/tmux is dead, update.
|
|
782
|
+
// We already filtered out completed/zombie states above, so if dead
|
|
783
|
+
// this session needs to be marked as zombie.
|
|
784
|
+
if (!alive) {
|
|
785
|
+
store.updateState(COORDINATOR_NAME, "zombie");
|
|
786
|
+
session.state = "zombie";
|
|
787
|
+
}
|
|
788
|
+
const status = {
|
|
789
|
+
running: alive,
|
|
790
|
+
headless: isHeadless,
|
|
791
|
+
sessionId: session.id,
|
|
792
|
+
state: session.state,
|
|
793
|
+
tmuxSession: session.tmuxSession,
|
|
794
|
+
pid: session.pid,
|
|
795
|
+
startedAt: session.startedAt,
|
|
796
|
+
lastActivity: session.lastActivity,
|
|
797
|
+
watchdogRunning,
|
|
798
|
+
monitorRunning,
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
if (json) {
|
|
802
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
803
|
+
} else {
|
|
804
|
+
const stateLabel = alive ? "running" : session.state;
|
|
805
|
+
process.stdout.write(`Coordinator: ${stateLabel}\n`);
|
|
806
|
+
process.stdout.write(` Session: ${session.id}\n`);
|
|
807
|
+
process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
|
|
808
|
+
process.stdout.write(` PID: ${session.pid}\n`);
|
|
809
|
+
process.stdout.write(` Started: ${session.startedAt}\n`);
|
|
810
|
+
process.stdout.write(` Activity: ${session.lastActivity}\n`);
|
|
811
|
+
process.stdout.write(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
|
|
812
|
+
process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
|
|
813
|
+
}
|
|
814
|
+
} finally {
|
|
815
|
+
store.close();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const COORDINATOR_HELP = `legio coordinator — Manage the persistent coordinator agent
|
|
820
|
+
|
|
821
|
+
Usage: legio coordinator <subcommand> [flags]
|
|
822
|
+
|
|
823
|
+
Subcommands:
|
|
824
|
+
start Start the coordinator (spawns Claude Code at project root)
|
|
825
|
+
stop Stop the coordinator (kills tmux session)
|
|
826
|
+
status Show coordinator state
|
|
827
|
+
|
|
828
|
+
Start options:
|
|
829
|
+
--attach Always attach to tmux session after start
|
|
830
|
+
--no-attach Never attach to tmux session after start
|
|
831
|
+
Default: attach when running in an interactive TTY
|
|
832
|
+
--headless Start without tmux — use PTY subprocess (no terminal UI)
|
|
833
|
+
--watchdog Auto-start watchdog daemon with coordinator
|
|
834
|
+
--monitor Auto-start monitor agent (Tier 2) with coordinator
|
|
835
|
+
|
|
836
|
+
General options:
|
|
837
|
+
--json Output as JSON
|
|
838
|
+
--help, -h Show this help
|
|
839
|
+
|
|
840
|
+
The coordinator runs at the project root and orchestrates work by:
|
|
841
|
+
- Decomposing objectives into beads issues
|
|
842
|
+
- Dispatching agents via legio sling
|
|
843
|
+
- Tracking batches via task groups
|
|
844
|
+
- Handling escalations from agents and watchdog`;
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Entry point for `legio coordinator <subcommand>`.
|
|
848
|
+
*
|
|
849
|
+
* @param args - CLI arguments after "coordinator"
|
|
850
|
+
* @param deps - Optional dependency injection for testing (tmux)
|
|
851
|
+
*/
|
|
852
|
+
export async function coordinatorCommand(
|
|
853
|
+
args: string[],
|
|
854
|
+
deps: CoordinatorDeps = {},
|
|
855
|
+
): Promise<void> {
|
|
856
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
857
|
+
process.stdout.write(`${COORDINATOR_HELP}\n`);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const subcommand = args[0];
|
|
862
|
+
const subArgs = args.slice(1);
|
|
863
|
+
|
|
864
|
+
switch (subcommand) {
|
|
865
|
+
case "start":
|
|
866
|
+
await startCoordinator(subArgs, deps);
|
|
867
|
+
break;
|
|
868
|
+
case "stop":
|
|
869
|
+
await stopCoordinator(subArgs, deps);
|
|
870
|
+
break;
|
|
871
|
+
case "status":
|
|
872
|
+
await statusCoordinator(subArgs, deps);
|
|
873
|
+
break;
|
|
874
|
+
default:
|
|
875
|
+
throw new ValidationError(
|
|
876
|
+
`Unknown coordinator subcommand: ${subcommand}. Run 'legio coordinator --help' for usage.`,
|
|
877
|
+
{ field: "subcommand", value: subcommand },
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|