@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +61 -3
  2. package/README.md +21 -10
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/package.json +2 -2
  14. package/src/agents/hooks-deployer.test.ts +46 -41
  15. package/src/agents/hooks-deployer.ts +10 -9
  16. package/src/agents/manifest.test.ts +6 -2
  17. package/src/agents/overlay.test.ts +9 -7
  18. package/src/agents/overlay.ts +29 -7
  19. package/src/commands/agents.test.ts +1 -5
  20. package/src/commands/clean.test.ts +2 -5
  21. package/src/commands/clean.ts +25 -1
  22. package/src/commands/completions.test.ts +1 -1
  23. package/src/commands/completions.ts +26 -7
  24. package/src/commands/coordinator.test.ts +87 -82
  25. package/src/commands/coordinator.ts +94 -48
  26. package/src/commands/costs.test.ts +2 -6
  27. package/src/commands/dashboard.test.ts +2 -5
  28. package/src/commands/doctor.test.ts +2 -6
  29. package/src/commands/down.ts +3 -3
  30. package/src/commands/errors.test.ts +2 -6
  31. package/src/commands/feed.test.ts +2 -6
  32. package/src/commands/gateway.test.ts +43 -17
  33. package/src/commands/gateway.ts +101 -11
  34. package/src/commands/hooks.test.ts +2 -5
  35. package/src/commands/init.test.ts +4 -13
  36. package/src/commands/inspect.test.ts +2 -6
  37. package/src/commands/log.test.ts +2 -6
  38. package/src/commands/logs.test.ts +2 -9
  39. package/src/commands/mail.test.ts +76 -215
  40. package/src/commands/mail.ts +43 -187
  41. package/src/commands/metrics.test.ts +3 -10
  42. package/src/commands/nudge.ts +15 -0
  43. package/src/commands/prime.test.ts +4 -11
  44. package/src/commands/replay.test.ts +2 -6
  45. package/src/commands/server.test.ts +1 -5
  46. package/src/commands/server.ts +1 -1
  47. package/src/commands/sling.test.ts +6 -1
  48. package/src/commands/sling.ts +42 -17
  49. package/src/commands/spec.test.ts +2 -5
  50. package/src/commands/status.test.ts +2 -4
  51. package/src/commands/stop.test.ts +2 -5
  52. package/src/commands/supervisor.ts +6 -6
  53. package/src/commands/trace.test.ts +2 -6
  54. package/src/commands/up.test.ts +43 -9
  55. package/src/commands/up.ts +15 -11
  56. package/src/commands/watchman.ts +327 -0
  57. package/src/commands/worktree.test.ts +2 -6
  58. package/src/config.test.ts +34 -104
  59. package/src/config.ts +120 -32
  60. package/src/doctor/agents.test.ts +52 -2
  61. package/src/doctor/agents.ts +4 -2
  62. package/src/doctor/config-check.test.ts +7 -2
  63. package/src/doctor/consistency.test.ts +7 -2
  64. package/src/doctor/databases.test.ts +6 -2
  65. package/src/doctor/dependencies.test.ts +18 -13
  66. package/src/doctor/dependencies.ts +23 -94
  67. package/src/doctor/logs.test.ts +7 -2
  68. package/src/doctor/merge-queue.test.ts +6 -2
  69. package/src/doctor/structure.test.ts +7 -2
  70. package/src/doctor/version.test.ts +7 -2
  71. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  72. package/src/index.ts +7 -7
  73. package/src/mail/pending.ts +120 -0
  74. package/src/mail/store.test.ts +89 -0
  75. package/src/mail/store.ts +11 -0
  76. package/src/merge/resolver.test.ts +518 -489
  77. package/src/server/index.ts +33 -2
  78. package/src/server/public/app.js +3 -3
  79. package/src/server/public/components/message-bubble.js +11 -1
  80. package/src/server/public/components/terminal-panel.js +66 -74
  81. package/src/server/public/views/chat.js +18 -2
  82. package/src/server/public/views/costs.js +5 -5
  83. package/src/server/public/views/dashboard.js +80 -51
  84. package/src/server/public/views/gateway-chat.js +37 -131
  85. package/src/server/public/views/inspect.js +16 -4
  86. package/src/server/public/views/issues.js +16 -12
  87. package/src/server/routes.test.ts +55 -39
  88. package/src/server/routes.ts +38 -26
  89. package/src/test-helpers.ts +6 -3
  90. package/src/tracker/beads.ts +159 -0
  91. package/src/tracker/exec.ts +44 -0
  92. package/src/tracker/factory.test.ts +283 -0
  93. package/src/tracker/factory.ts +59 -0
  94. package/src/tracker/seeds.ts +156 -0
  95. package/src/tracker/types.ts +46 -0
  96. package/src/types.ts +11 -2
  97. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  98. package/src/watchman/daemon.ts +940 -0
  99. package/src/worktree/tmux.test.ts +2 -1
  100. package/src/worktree/tmux.ts +4 -4
  101. package/templates/hooks.json.tmpl +17 -17
  102. package/src/beads/client.test.ts +0 -210
  103. package/src/commands/merge.test.ts +0 -676
  104. package/src/commands/watch.test.ts +0 -152
  105. package/src/commands/watch.ts +0 -238
  106. package/src/test-helpers.test.ts +0 -97
  107. package/src/watchdog/daemon.ts +0 -533
  108. package/src/watchdog/health.test.ts +0 -371
  109. package/src/watchdog/triage.test.ts +0 -162
  110. package/src/worktree/manager.test.ts +0 -444
  111. /package/src/{watchdog → watchman}/health.ts +0 -0
  112. /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 "../watchdog/health.ts";
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
- _watchdog?: {
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 watchdog PID file.
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 readWatchdogPid(projectRoot: string): Promise<number | null> {
137
- const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
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 watchdog PID file.
155
+ * Remove the watchman PID file.
156
156
  */
157
- async function removeWatchdogPid(projectRoot: string): Promise<void> {
158
- const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
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 watchdog implementation for production use.
168
- * Starts/stops the watchdog daemon via `legio watch --background`.
167
+ * Default watchman implementation for production use.
168
+ * Starts/stops the watchman daemon via `legio watchman start --background`.
169
169
  */
170
- function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
170
+ function createDefaultWatchman(projectRoot: string): NonNullable<CoordinatorDeps["_watchman"]> {
171
171
  return {
172
172
  async start(): Promise<{ pid: number } | null> {
173
- // Check if watchdog is already running
174
- const existingPid = await readWatchdogPid(projectRoot);
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 removeWatchdogPid(projectRoot);
181
+ await removeWatchmanPid(projectRoot);
182
182
  }
183
183
 
184
- // Start watchdog in background
185
- const { exitCode } = await runProcess(["legio", "watch", "--background"], {
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 readWatchdogPid(projectRoot);
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 readWatchdogPid(projectRoot);
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 removeWatchdogPid(projectRoot);
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 removeWatchdogPid(projectRoot);
222
+ await removeWatchmanPid(projectRoot);
223
223
  return true;
224
224
  },
225
225
 
226
226
  async isRunning(): Promise<boolean> {
227
- const pid = await readWatchdogPid(projectRoot);
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 bd ready, check legio group status, then begin work`,
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 watchdogFlag = args.includes("--watchdog");
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 watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
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
- watchdog: false,
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
- // 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`);
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(" Watchdog: failed to start or already running\n");
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 watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
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 watchdog
666
- const watchdogStopped = await watchdog.stop();
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, watchdogStopped, monitorStopped, runCompleted })}\n`,
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 (watchdogStopped) {
707
- process.stdout.write("Watchdog stopped\n");
751
+ if (watchmanStopped) {
752
+ process.stdout.write("Watchman stopped\n");
708
753
  } else {
709
- process.stdout.write("No watchdog running\n");
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 watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
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 watchdogRunning = await watchdog.isRunning();
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, watchdogRunning, monitorRunning })}\n`,
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 (watchdogRunning) {
762
- process.stdout.write("Watchdog: running\n");
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
- watchdogRunning,
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(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
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
- --watchdog Auto-start watchdog daemon with coordinator
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 watchdog`;
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
- // Change to temp dir so loadConfig() works
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
- const originalCwd = process.cwd();
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
- // Change to temp dir so loadConfig() works
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
 
@@ -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 watchdog and monitor agents.
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 watchdog and monitor), the
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 watchdog + monitor
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
- // Change to temp dir so loadConfig() works
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
- // Change to temp dir so loadConfig() works
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
- // Override cwd so gateway commands find our temp project
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 gateway agent name for event logging", async () => {
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
- // The hooks should reference the gateway agent name
329
- expect(content).toContain("--agent gateway");
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: Use bd create");
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 4 " — " separators (5 parts)
728
+ // Should have exactly 5 " — " separators (6 parts)
703
729
  const dashes = beacon.split(" — ");
704
- expect(dashes).toHaveLength(5);
730
+ expect(dashes).toHaveLength(6);
705
731
  });
706
732
 
707
733
  test("default (no args) does not include FIRST_RUN", () => {