@katyella/legio 0.1.3 → 0.2.2
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 +61 -3
- package/README.md +21 -10
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +87 -82
- package/src/commands/coordinator.ts +94 -48
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +43 -17
- package/src/commands/gateway.ts +101 -11
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.test.ts +6 -1
- package/src/commands/sling.ts +42 -17
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +52 -2
- package/src/doctor/agents.ts +4 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +18 -13
- package/src/doctor/dependencies.ts +23 -94
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
|
@@ -22,9 +22,9 @@ import { collectProviderEnv, loadConfig } from "../config.ts";
|
|
|
22
22
|
import { AgentError, isRunningAsRoot, ValidationError } from "../errors.ts";
|
|
23
23
|
import { HeadlessCoordinator } from "../server/headless.ts";
|
|
24
24
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
25
|
-
import { createRunStore } from "../sessions/store.ts";
|
|
25
|
+
import { createRunStore, type SessionStore } from "../sessions/store.ts";
|
|
26
26
|
import type { AgentSession, HeadlessCoordinatorConfig } from "../types.ts";
|
|
27
|
-
import { isProcessRunning } from "../
|
|
27
|
+
import { isProcessRunning } from "../watchman/health.ts";
|
|
28
28
|
import {
|
|
29
29
|
createSession,
|
|
30
30
|
isSessionAlive,
|
|
@@ -113,7 +113,7 @@ export interface CoordinatorDeps {
|
|
|
113
113
|
opts?: { timeout?: number; interval?: number },
|
|
114
114
|
) => Promise<void>;
|
|
115
115
|
};
|
|
116
|
-
|
|
116
|
+
_watchman?: {
|
|
117
117
|
start: () => Promise<{ pid: number } | null>;
|
|
118
118
|
stop: () => Promise<boolean>;
|
|
119
119
|
isRunning: () => Promise<boolean>;
|
|
@@ -130,11 +130,11 @@ export interface CoordinatorDeps {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* Read the PID from the
|
|
133
|
+
* Read the PID from the watchman PID file.
|
|
134
134
|
* Returns null if the file doesn't exist or can't be parsed.
|
|
135
135
|
*/
|
|
136
|
-
async function
|
|
137
|
-
const pidFilePath = join(projectRoot, ".legio", "
|
|
136
|
+
async function readWatchmanPid(projectRoot: string): Promise<number | null> {
|
|
137
|
+
const pidFilePath = join(projectRoot, ".legio", "watchman.pid");
|
|
138
138
|
if (!(await fileExists(pidFilePath))) {
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
@@ -152,10 +152,10 @@ async function readWatchdogPid(projectRoot: string): Promise<number | null> {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
|
-
* Remove the
|
|
155
|
+
* Remove the watchman PID file.
|
|
156
156
|
*/
|
|
157
|
-
async function
|
|
158
|
-
const pidFilePath = join(projectRoot, ".legio", "
|
|
157
|
+
async function removeWatchmanPid(projectRoot: string): Promise<void> {
|
|
158
|
+
const pidFilePath = join(projectRoot, ".legio", "watchman.pid");
|
|
159
159
|
try {
|
|
160
160
|
await unlink(pidFilePath);
|
|
161
161
|
} catch {
|
|
@@ -164,25 +164,25 @@ async function removeWatchdogPid(projectRoot: string): Promise<void> {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
|
-
* Default
|
|
168
|
-
* Starts/stops the
|
|
167
|
+
* Default watchman implementation for production use.
|
|
168
|
+
* Starts/stops the watchman daemon via `legio watchman start --background`.
|
|
169
169
|
*/
|
|
170
|
-
function
|
|
170
|
+
function createDefaultWatchman(projectRoot: string): NonNullable<CoordinatorDeps["_watchman"]> {
|
|
171
171
|
return {
|
|
172
172
|
async start(): Promise<{ pid: number } | null> {
|
|
173
|
-
// Check if
|
|
174
|
-
const existingPid = await
|
|
173
|
+
// Check if watchman is already running
|
|
174
|
+
const existingPid = await readWatchmanPid(projectRoot);
|
|
175
175
|
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
176
176
|
return null; // Already running
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// Clean up stale PID file
|
|
180
180
|
if (existingPid !== null) {
|
|
181
|
-
await
|
|
181
|
+
await removeWatchmanPid(projectRoot);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Start
|
|
185
|
-
const { exitCode } = await runProcess(["legio", "
|
|
184
|
+
// Start watchman in background
|
|
185
|
+
const { exitCode } = await runProcess(["legio", "watchman", "start", "--background"], {
|
|
186
186
|
cwd: projectRoot,
|
|
187
187
|
});
|
|
188
188
|
if (exitCode !== 0) {
|
|
@@ -190,7 +190,7 @@ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// Read the PID file that was written by the background process
|
|
193
|
-
const pid = await
|
|
193
|
+
const pid = await readWatchmanPid(projectRoot);
|
|
194
194
|
if (pid === null) {
|
|
195
195
|
return null; // PID file wasn't created
|
|
196
196
|
}
|
|
@@ -199,7 +199,7 @@ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps
|
|
|
199
199
|
},
|
|
200
200
|
|
|
201
201
|
async stop(): Promise<boolean> {
|
|
202
|
-
const pid = await
|
|
202
|
+
const pid = await readWatchmanPid(projectRoot);
|
|
203
203
|
if (pid === null) {
|
|
204
204
|
return false; // No PID file
|
|
205
205
|
}
|
|
@@ -207,7 +207,7 @@ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps
|
|
|
207
207
|
// Check if process is running
|
|
208
208
|
if (!isProcessRunning(pid)) {
|
|
209
209
|
// Process is dead, clean up PID file
|
|
210
|
-
await
|
|
210
|
+
await removeWatchmanPid(projectRoot);
|
|
211
211
|
return false;
|
|
212
212
|
}
|
|
213
213
|
|
|
@@ -219,12 +219,12 @@ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
// Remove PID file
|
|
222
|
-
await
|
|
222
|
+
await removeWatchmanPid(projectRoot);
|
|
223
223
|
return true;
|
|
224
224
|
},
|
|
225
225
|
|
|
226
226
|
async isRunning(): Promise<boolean> {
|
|
227
|
-
const pid = await
|
|
227
|
+
const pid = await readWatchmanPid(projectRoot);
|
|
228
228
|
if (pid === null) {
|
|
229
229
|
return false;
|
|
230
230
|
}
|
|
@@ -281,10 +281,11 @@ export function buildCoordinatorBeacon(): string {
|
|
|
281
281
|
const timestamp = new Date().toISOString();
|
|
282
282
|
const parts = [
|
|
283
283
|
`[LEGIO] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
|
|
284
|
+
"You are a coordinator agent in the legio multi-agent orchestration system. legio is a CLI tool installed on this machine that coordinates multiple Claude Code agents via tmux, SQLite mail, and git worktrees.",
|
|
284
285
|
"Depth: 0 | Parent: none | Role: persistent orchestrator",
|
|
285
286
|
"HIERARCHY: You ONLY spawn leads (legio sling --capability lead). Leads spawn scouts, builders, reviewers. NEVER spawn non-lead agents directly.",
|
|
286
287
|
"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
|
|
288
|
+
`Startup: run mulch prime, check mail (legio mail check --agent ${COORDINATOR_NAME}), check legio status, then begin work`,
|
|
288
289
|
];
|
|
289
290
|
return parts.join(" — ");
|
|
290
291
|
}
|
|
@@ -310,6 +311,46 @@ export function resolveAttach(args: string[], isTTY: boolean): boolean {
|
|
|
310
311
|
return isTTY;
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Verify that the coordinator beacon was delivered by polling the session state.
|
|
316
|
+
* If the session remains in "booting" after 10 seconds, retries the beacon once.
|
|
317
|
+
* Logs a warning if delivery cannot be confirmed.
|
|
318
|
+
*
|
|
319
|
+
* Must be awaited before the SessionStore is closed (called inside the try block).
|
|
320
|
+
*/
|
|
321
|
+
async function verifyBeaconDelivery(
|
|
322
|
+
store: SessionStore,
|
|
323
|
+
tmux: NonNullable<CoordinatorDeps["_tmux"]>,
|
|
324
|
+
tmuxSession: string,
|
|
325
|
+
beacon: string,
|
|
326
|
+
sleep: (ms: number) => Promise<void>,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const MAX_CHECKS = 10;
|
|
329
|
+
const INTERVAL_MS = 2_000;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < MAX_CHECKS; i++) {
|
|
332
|
+
await sleep(INTERVAL_MS);
|
|
333
|
+
const session = store.getByName(COORDINATOR_NAME);
|
|
334
|
+
if (session && session.state !== "booting") {
|
|
335
|
+
// Beacon confirmed — coordinator transitioned out of booting
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Still booting after MAX_CHECKS — retry beacon once
|
|
341
|
+
process.stderr.write("[legio] Beacon delivery unconfirmed — retrying beacon\n");
|
|
342
|
+
await tmux.sendKeys(tmuxSession, beacon);
|
|
343
|
+
await sleep(500);
|
|
344
|
+
await tmux.sendKeys(tmuxSession, "");
|
|
345
|
+
|
|
346
|
+
// One final check after retry
|
|
347
|
+
await sleep(INTERVAL_MS);
|
|
348
|
+
const finalSession = store.getByName(COORDINATOR_NAME);
|
|
349
|
+
if (!finalSession || finalSession.state === "booting") {
|
|
350
|
+
process.stderr.write("[legio] Warning: coordinator beacon delivery could not be confirmed\n");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
313
354
|
async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
|
|
314
355
|
const tmux = deps._tmux ?? {
|
|
315
356
|
createSession,
|
|
@@ -332,13 +373,13 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
|
|
|
332
373
|
|
|
333
374
|
const json = args.includes("--json");
|
|
334
375
|
const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
|
|
335
|
-
const
|
|
376
|
+
const watchmanFlag = args.includes("--watchman") || args.includes("--watchdog");
|
|
336
377
|
const monitorFlag = args.includes("--monitor");
|
|
337
378
|
const headlessFlag = args.includes("--headless");
|
|
338
379
|
const cwd = process.cwd();
|
|
339
380
|
const config = await loadConfig(cwd);
|
|
340
381
|
const projectRoot = config.project.root;
|
|
341
|
-
const
|
|
382
|
+
const watchman = deps._watchman ?? createDefaultWatchman(projectRoot);
|
|
342
383
|
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
343
384
|
const tmuxSession = coordinatorTmuxSession(config.project.name);
|
|
344
385
|
|
|
@@ -533,7 +574,7 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
|
|
|
533
574
|
tmuxSession,
|
|
534
575
|
projectRoot,
|
|
535
576
|
pid,
|
|
536
|
-
|
|
577
|
+
watchman: false,
|
|
537
578
|
monitor: false,
|
|
538
579
|
};
|
|
539
580
|
|
|
@@ -560,13 +601,17 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
|
|
|
560
601
|
await sleep(500);
|
|
561
602
|
await tmux.sendKeys(tmuxSession, "");
|
|
562
603
|
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
604
|
+
// Verify beacon delivery: poll store until state transitions out of booting.
|
|
605
|
+
// Must complete before the finally block closes the store.
|
|
606
|
+
await verifyBeaconDelivery(store, tmux, tmuxSession, beacon, sleep);
|
|
607
|
+
|
|
608
|
+
// Auto-start watchman if --watchman/--watchdog flag is present
|
|
609
|
+
if (watchmanFlag) {
|
|
610
|
+
const watchmanResult = await watchman.start();
|
|
611
|
+
if (watchmanResult) {
|
|
612
|
+
if (!json) process.stdout.write(` Watchman: started (PID ${watchmanResult.pid})\n`);
|
|
568
613
|
} else {
|
|
569
|
-
if (!json) process.stderr.write("
|
|
614
|
+
if (!json) process.stderr.write(" Watchman: failed to start or already running\n");
|
|
570
615
|
}
|
|
571
616
|
}
|
|
572
617
|
|
|
@@ -605,7 +650,7 @@ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Prom
|
|
|
605
650
|
const cwd = process.cwd();
|
|
606
651
|
const config = await loadConfig(cwd);
|
|
607
652
|
const projectRoot = config.project.root;
|
|
608
|
-
const
|
|
653
|
+
const watchman = deps._watchman ?? createDefaultWatchman(projectRoot);
|
|
609
654
|
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
610
655
|
|
|
611
656
|
const legioDir = join(projectRoot, ".legio");
|
|
@@ -662,8 +707,8 @@ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Prom
|
|
|
662
707
|
}
|
|
663
708
|
}
|
|
664
709
|
|
|
665
|
-
// Always attempt to stop
|
|
666
|
-
const
|
|
710
|
+
// Always attempt to stop watchman
|
|
711
|
+
const watchmanStopped = await watchman.stop();
|
|
667
712
|
|
|
668
713
|
// Always attempt to stop monitor
|
|
669
714
|
const monitorStopped = await monitor.stop();
|
|
@@ -699,14 +744,14 @@ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Prom
|
|
|
699
744
|
|
|
700
745
|
if (json) {
|
|
701
746
|
process.stdout.write(
|
|
702
|
-
`${JSON.stringify({ stopped: true, sessionId: session.id,
|
|
747
|
+
`${JSON.stringify({ stopped: true, sessionId: session.id, watchmanStopped, monitorStopped, runCompleted })}\n`,
|
|
703
748
|
);
|
|
704
749
|
} else {
|
|
705
750
|
process.stdout.write(`Coordinator stopped (session: ${session.id})\n`);
|
|
706
|
-
if (
|
|
707
|
-
process.stdout.write("
|
|
751
|
+
if (watchmanStopped) {
|
|
752
|
+
process.stdout.write("Watchman stopped\n");
|
|
708
753
|
} else {
|
|
709
|
-
process.stdout.write("No
|
|
754
|
+
process.stdout.write("No watchman running\n");
|
|
710
755
|
}
|
|
711
756
|
if (monitorStopped) {
|
|
712
757
|
process.stdout.write("Monitor stopped\n");
|
|
@@ -736,14 +781,14 @@ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Pr
|
|
|
736
781
|
const cwd = process.cwd();
|
|
737
782
|
const config = await loadConfig(cwd);
|
|
738
783
|
const projectRoot = config.project.root;
|
|
739
|
-
const
|
|
784
|
+
const watchman = deps._watchman ?? createDefaultWatchman(projectRoot);
|
|
740
785
|
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
741
786
|
|
|
742
787
|
const legioDir = join(projectRoot, ".legio");
|
|
743
788
|
const { store } = openSessionStore(legioDir);
|
|
744
789
|
try {
|
|
745
790
|
const session = store.getByName(COORDINATOR_NAME);
|
|
746
|
-
const
|
|
791
|
+
const watchmanRunning = await watchman.isRunning();
|
|
747
792
|
const monitorRunning = await monitor.isRunning();
|
|
748
793
|
|
|
749
794
|
if (
|
|
@@ -754,12 +799,12 @@ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Pr
|
|
|
754
799
|
) {
|
|
755
800
|
if (json) {
|
|
756
801
|
process.stdout.write(
|
|
757
|
-
`${JSON.stringify({ running: false,
|
|
802
|
+
`${JSON.stringify({ running: false, watchmanRunning, monitorRunning })}\n`,
|
|
758
803
|
);
|
|
759
804
|
} else {
|
|
760
805
|
process.stdout.write("Coordinator is not running\n");
|
|
761
|
-
if (
|
|
762
|
-
process.stdout.write("
|
|
806
|
+
if (watchmanRunning) {
|
|
807
|
+
process.stdout.write("Watchman: running\n");
|
|
763
808
|
}
|
|
764
809
|
if (monitorRunning) {
|
|
765
810
|
process.stdout.write("Monitor: running\n");
|
|
@@ -794,7 +839,7 @@ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Pr
|
|
|
794
839
|
pid: session.pid,
|
|
795
840
|
startedAt: session.startedAt,
|
|
796
841
|
lastActivity: session.lastActivity,
|
|
797
|
-
|
|
842
|
+
watchmanRunning,
|
|
798
843
|
monitorRunning,
|
|
799
844
|
};
|
|
800
845
|
|
|
@@ -808,7 +853,7 @@ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Pr
|
|
|
808
853
|
process.stdout.write(` PID: ${session.pid}\n`);
|
|
809
854
|
process.stdout.write(` Started: ${session.startedAt}\n`);
|
|
810
855
|
process.stdout.write(` Activity: ${session.lastActivity}\n`);
|
|
811
|
-
process.stdout.write(`
|
|
856
|
+
process.stdout.write(` Watchman: ${watchmanRunning ? "running" : "not running"}\n`);
|
|
812
857
|
process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
|
|
813
858
|
}
|
|
814
859
|
} finally {
|
|
@@ -830,7 +875,8 @@ Start options:
|
|
|
830
875
|
--no-attach Never attach to tmux session after start
|
|
831
876
|
Default: attach when running in an interactive TTY
|
|
832
877
|
--headless Start without tmux — use PTY subprocess (no terminal UI)
|
|
833
|
-
--
|
|
878
|
+
--watchman Auto-start watchman daemon with coordinator
|
|
879
|
+
--watchdog Alias for --watchman
|
|
834
880
|
--monitor Auto-start monitor agent (Tier 2) with coordinator
|
|
835
881
|
|
|
836
882
|
General options:
|
|
@@ -841,7 +887,7 @@ The coordinator runs at the project root and orchestrates work by:
|
|
|
841
887
|
- Decomposing objectives into beads issues
|
|
842
888
|
- Dispatching agents via legio sling
|
|
843
889
|
- Tracking batches via task groups
|
|
844
|
-
- Handling escalations from agents and
|
|
890
|
+
- Handling escalations from agents and watchman`;
|
|
845
891
|
|
|
846
892
|
/**
|
|
847
893
|
* Entry point for `legio coordinator <subcommand>`.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
16
16
|
import { createMetricsStore } from "../metrics/store.ts";
|
|
17
17
|
import { createSessionStore } from "../sessions/store.ts";
|
|
@@ -44,7 +44,6 @@ describe("costsCommand", () => {
|
|
|
44
44
|
let chunks: string[];
|
|
45
45
|
let originalWrite: typeof process.stdout.write;
|
|
46
46
|
let tempDir: string;
|
|
47
|
-
let originalCwd: string;
|
|
48
47
|
|
|
49
48
|
beforeEach(async () => {
|
|
50
49
|
// Spy on stdout
|
|
@@ -64,14 +63,11 @@ describe("costsCommand", () => {
|
|
|
64
63
|
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
65
64
|
);
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
originalCwd = process.cwd();
|
|
69
|
-
process.chdir(tempDir);
|
|
66
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
70
67
|
});
|
|
71
68
|
|
|
72
69
|
afterEach(async () => {
|
|
73
70
|
process.stdout.write = originalWrite;
|
|
74
|
-
process.chdir(originalCwd);
|
|
75
71
|
await rm(tempDir, { recursive: true, force: true });
|
|
76
72
|
});
|
|
77
73
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
13
|
import { ValidationError } from "../errors.ts";
|
|
14
14
|
import { dashboardCommand } from "./dashboard.ts";
|
|
15
15
|
|
|
@@ -74,10 +74,9 @@ describe("dashboardCommand", () => {
|
|
|
74
74
|
// throws BEFORE the infinite while loop starts. This proves validation passed
|
|
75
75
|
// (no ValidationError about interval) while preventing the loop from leaking.
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
78
78
|
|
|
79
79
|
try {
|
|
80
|
-
process.chdir(tempDir);
|
|
81
80
|
await dashboardCommand(["--interval", "500"]);
|
|
82
81
|
} catch (err) {
|
|
83
82
|
// If it's a ValidationError about interval, the test should fail
|
|
@@ -85,8 +84,6 @@ describe("dashboardCommand", () => {
|
|
|
85
84
|
throw new Error("Interval validation should have passed for value 500");
|
|
86
85
|
}
|
|
87
86
|
// Other errors (like from loadConfig) are expected - they occur after validation passed
|
|
88
|
-
} finally {
|
|
89
|
-
process.chdir(originalCwd);
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
// If we reach here without throwing a ValidationError about interval, validation passed
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
16
16
|
import { ValidationError } from "../errors.ts";
|
|
17
17
|
import { doctorCommand } from "./doctor.ts";
|
|
18
18
|
|
|
@@ -20,7 +20,6 @@ describe("doctorCommand", () => {
|
|
|
20
20
|
let chunks: string[];
|
|
21
21
|
let originalWrite: typeof process.stdout.write;
|
|
22
22
|
let tempDir: string;
|
|
23
|
-
let originalCwd: string;
|
|
24
23
|
|
|
25
24
|
beforeEach(async () => {
|
|
26
25
|
// Spy on stdout
|
|
@@ -40,14 +39,11 @@ describe("doctorCommand", () => {
|
|
|
40
39
|
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
41
40
|
);
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
originalCwd = process.cwd();
|
|
45
|
-
process.chdir(tempDir);
|
|
42
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
46
43
|
});
|
|
47
44
|
|
|
48
45
|
afterEach(async () => {
|
|
49
46
|
process.stdout.write = originalWrite;
|
|
50
|
-
process.chdir(originalCwd);
|
|
51
47
|
await rm(tempDir, { recursive: true, force: true });
|
|
52
48
|
});
|
|
53
49
|
|
package/src/commands/down.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single command to cleanly stop the full legio stack:
|
|
5
5
|
* 1. Stop coordinator (if running) via legio coordinator stop
|
|
6
|
-
* This also stops the
|
|
6
|
+
* This also stops the watchman and monitor agents.
|
|
7
7
|
* 2. Stop gateway (if running) via legio gateway stop
|
|
8
8
|
* 3. Stop server (if running) via legio server stop
|
|
9
9
|
*
|
|
@@ -63,7 +63,7 @@ Options:
|
|
|
63
63
|
--json JSON output
|
|
64
64
|
--help, -h Show this help
|
|
65
65
|
|
|
66
|
-
legio down stops the coordinator (including
|
|
66
|
+
legio down stops the coordinator (including watchman and monitor), the
|
|
67
67
|
gateway agent, and the server. Running legio down when nothing is running
|
|
68
68
|
is a safe no-op.`;
|
|
69
69
|
|
|
@@ -87,7 +87,7 @@ export async function downCommand(args: string[], deps: DownDeps = {}): Promise<
|
|
|
87
87
|
let gatewayStopped = false;
|
|
88
88
|
let serverStopped = false;
|
|
89
89
|
|
|
90
|
-
// 1. Stop coordinator (if running) — also stops
|
|
90
|
+
// 1. Stop coordinator (if running) — also stops watchman + monitor
|
|
91
91
|
const coordStop = await run(["legio", "coordinator", "stop"], { cwd: projectRoot });
|
|
92
92
|
if (coordStop.exitCode === 0) {
|
|
93
93
|
coordinatorStopped = true;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
16
16
|
import { createEventStore } from "../events/store.ts";
|
|
17
17
|
import type { InsertEvent } from "../types.ts";
|
|
@@ -37,7 +37,6 @@ describe("errorsCommand", () => {
|
|
|
37
37
|
let chunks: string[];
|
|
38
38
|
let originalWrite: typeof process.stdout.write;
|
|
39
39
|
let tempDir: string;
|
|
40
|
-
let originalCwd: string;
|
|
41
40
|
|
|
42
41
|
beforeEach(async () => {
|
|
43
42
|
// Spy on stdout
|
|
@@ -57,14 +56,11 @@ describe("errorsCommand", () => {
|
|
|
57
56
|
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
58
57
|
);
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
originalCwd = process.cwd();
|
|
62
|
-
process.chdir(tempDir);
|
|
59
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
afterEach(async () => {
|
|
66
63
|
process.stdout.write = originalWrite;
|
|
67
|
-
process.chdir(originalCwd);
|
|
68
64
|
await rm(tempDir, { recursive: true, force: true });
|
|
69
65
|
});
|
|
70
66
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
16
16
|
import { createEventStore } from "../events/store.ts";
|
|
17
17
|
import type { InsertEvent } from "../types.ts";
|
|
@@ -37,7 +37,6 @@ describe("feedCommand", () => {
|
|
|
37
37
|
let chunks: string[];
|
|
38
38
|
let originalWrite: typeof process.stdout.write;
|
|
39
39
|
let tempDir: string;
|
|
40
|
-
let originalCwd: string;
|
|
41
40
|
|
|
42
41
|
beforeEach(async () => {
|
|
43
42
|
// Spy on stdout
|
|
@@ -57,14 +56,11 @@ describe("feedCommand", () => {
|
|
|
57
56
|
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
58
57
|
);
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
originalCwd = process.cwd();
|
|
62
|
-
process.chdir(tempDir);
|
|
59
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
afterEach(async () => {
|
|
66
63
|
process.stdout.write = originalWrite;
|
|
67
|
-
process.chdir(originalCwd);
|
|
68
64
|
await rm(tempDir, { recursive: true, force: true });
|
|
69
65
|
});
|
|
70
66
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
17
17
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
18
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
19
19
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
@@ -68,6 +68,7 @@ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
|
|
|
68
68
|
sendKeys: async (name: string, keys: string): Promise<void> => {
|
|
69
69
|
calls.sendKeys.push({ name, keys });
|
|
70
70
|
},
|
|
71
|
+
waitForTuiReady: async (): Promise<void> => {},
|
|
71
72
|
};
|
|
72
73
|
|
|
73
74
|
return { tmux, calls };
|
|
@@ -77,7 +78,6 @@ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
|
|
|
77
78
|
|
|
78
79
|
let tempDir: string;
|
|
79
80
|
let legioDir: string;
|
|
80
|
-
const originalCwd = process.cwd();
|
|
81
81
|
|
|
82
82
|
/** Save sessions to the SessionStore (sessions.db) for test setup. */
|
|
83
83
|
function saveSessionsToDb(sessions: AgentSession[]): void {
|
|
@@ -102,10 +102,6 @@ function loadSessionsFromDb(): AgentSession[] {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
beforeEach(async () => {
|
|
105
|
-
// Restore cwd FIRST so createTempGitRepo's git operations don't fail
|
|
106
|
-
// if a prior test's tempDir was already cleaned up.
|
|
107
|
-
process.chdir(originalCwd);
|
|
108
|
-
|
|
109
105
|
tempDir = await realpath(await createTempGitRepo());
|
|
110
106
|
legioDir = join(tempDir, ".legio");
|
|
111
107
|
await mkdir(legioDir, { recursive: true });
|
|
@@ -141,12 +137,10 @@ beforeEach(async () => {
|
|
|
141
137
|
);
|
|
142
138
|
await writeFile(join(agentDefsDir, "gateway.md"), "# Gateway\n");
|
|
143
139
|
|
|
144
|
-
|
|
145
|
-
process.chdir(tempDir);
|
|
140
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
146
141
|
}, 30000); // 30s timeout: createTempGitRepo can be slow on first run
|
|
147
142
|
|
|
148
143
|
afterEach(async () => {
|
|
149
|
-
process.chdir(originalCwd);
|
|
150
144
|
await cleanupTempDir(tempDir);
|
|
151
145
|
});
|
|
152
146
|
|
|
@@ -317,7 +311,7 @@ describe("startGateway", () => {
|
|
|
317
311
|
expect(config.hooks.Stop).toBeDefined();
|
|
318
312
|
});
|
|
319
313
|
|
|
320
|
-
test("hooks use
|
|
314
|
+
test("hooks use LEGIO_AGENT_NAME env var for event logging", async () => {
|
|
321
315
|
const { deps } = makeDeps();
|
|
322
316
|
|
|
323
317
|
await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
|
|
@@ -325,8 +319,8 @@ describe("startGateway", () => {
|
|
|
325
319
|
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
326
320
|
const content = await readFile(settingsPath, "utf-8");
|
|
327
321
|
|
|
328
|
-
//
|
|
329
|
-
expect(content).toContain("--agent
|
|
322
|
+
// Hooks reference the agent via $LEGIO_AGENT_NAME env var (not hardcoded)
|
|
323
|
+
expect(content).toContain("--agent $LEGIO_AGENT_NAME");
|
|
330
324
|
});
|
|
331
325
|
|
|
332
326
|
test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
|
|
@@ -509,6 +503,38 @@ describe("startGateway", () => {
|
|
|
509
503
|
// The new session should have a different ID than the dead one
|
|
510
504
|
expect(newSession?.id).not.toBe("session-dead-gateway");
|
|
511
505
|
});
|
|
506
|
+
|
|
507
|
+
test("re-sends beacon when session stays in booting state", async () => {
|
|
508
|
+
const { deps, calls } = makeDeps();
|
|
509
|
+
|
|
510
|
+
await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
|
|
511
|
+
|
|
512
|
+
// verifyBeaconDelivery polls store 10 times (all return "booting" since no hooks run),
|
|
513
|
+
// then retries the full beacon + follow-up Enter.
|
|
514
|
+
// Total sendKeys calls: 1 (initial beacon) + 1 (follow-up Enter) + 1 (retry beacon) + 1 (retry Enter)
|
|
515
|
+
const beaconCalls = calls.sendKeys.filter((c) => c.keys.includes("[LEGIO]"));
|
|
516
|
+
expect(beaconCalls).toHaveLength(2); // initial + retry
|
|
517
|
+
const enterCalls = calls.sendKeys.filter((c) => c.keys === "");
|
|
518
|
+
expect(enterCalls).toHaveLength(2); // follow-up Enter + retry Enter
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("sends greeting mail to human after beacon delivery", async () => {
|
|
522
|
+
const { deps } = makeDeps();
|
|
523
|
+
await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
|
|
524
|
+
// Verify mail.db has the greeting
|
|
525
|
+
const { createMailStore } = await import("../mail/store.ts");
|
|
526
|
+
const mailDb = createMailStore(join(legioDir, "mail.db"));
|
|
527
|
+
try {
|
|
528
|
+
const msgs = mailDb.getAll({ from: "gateway", to: "human" });
|
|
529
|
+
expect(msgs).toHaveLength(1);
|
|
530
|
+
expect(msgs[0]?.subject).toBe("Gateway online");
|
|
531
|
+
expect(msgs[0]?.body).toContain("online and ready");
|
|
532
|
+
expect(msgs[0]?.type).toBe("status");
|
|
533
|
+
expect(msgs[0]?.audience).toBe("human");
|
|
534
|
+
} finally {
|
|
535
|
+
mailDb.close();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
512
538
|
});
|
|
513
539
|
|
|
514
540
|
describe("stopGateway", () => {
|
|
@@ -687,21 +713,21 @@ describe("buildGatewayBeacon", () => {
|
|
|
687
713
|
|
|
688
714
|
test("includes ISSUES notice", () => {
|
|
689
715
|
const beacon = buildGatewayBeacon();
|
|
690
|
-
expect(beacon).toContain("ISSUES:
|
|
716
|
+
expect(beacon).toContain("ISSUES:");
|
|
717
|
+
expect(beacon).toContain("legio status");
|
|
691
718
|
});
|
|
692
719
|
|
|
693
720
|
test("includes startup instructions", () => {
|
|
694
721
|
const beacon = buildGatewayBeacon();
|
|
695
|
-
expect(beacon).toContain("mulch prime");
|
|
696
722
|
expect(beacon).toContain("legio mail check --agent gateway");
|
|
697
|
-
expect(beacon).toContain("respond to user");
|
|
723
|
+
expect(beacon).toContain("respond to user via BOTH terminal AND mail");
|
|
698
724
|
});
|
|
699
725
|
|
|
700
726
|
test("parts are joined with em-dash separator", () => {
|
|
701
727
|
const beacon = buildGatewayBeacon();
|
|
702
|
-
// Should have exactly
|
|
728
|
+
// Should have exactly 5 " — " separators (6 parts)
|
|
703
729
|
const dashes = beacon.split(" — ");
|
|
704
|
-
expect(dashes).toHaveLength(
|
|
730
|
+
expect(dashes).toHaveLength(6);
|
|
705
731
|
});
|
|
706
732
|
|
|
707
733
|
test("default (no args) does not include FIRST_RUN", () => {
|