@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Tests for src/commands/monitor.ts
3
+ *
4
+ * Note: We do NOT test start/stop/status subcommands here because they require
5
+ * tmux session management, which is fragile in test environments and interferes
6
+ * with developer tmux sessions. Those operations are covered by E2E testing.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
10
+ import { ValidationError } from "../errors.ts";
11
+ import { buildMonitorBeacon, monitorCommand } from "./monitor.ts";
12
+
13
+ describe("buildMonitorBeacon", () => {
14
+ test("contains monitor agent name", () => {
15
+ const beacon = buildMonitorBeacon();
16
+ expect(beacon).toContain("monitor");
17
+ });
18
+
19
+ test("contains tier-2 designation", () => {
20
+ const beacon = buildMonitorBeacon();
21
+ expect(beacon).toContain("tier-2");
22
+ });
23
+
24
+ test("contains ISO timestamp with today's date", () => {
25
+ const beacon = buildMonitorBeacon();
26
+ const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
27
+ if (!today) {
28
+ throw new Error("Failed to extract date from ISO string");
29
+ }
30
+ expect(beacon).toContain(today);
31
+ });
32
+
33
+ test("contains startup instruction: mulch prime", () => {
34
+ const beacon = buildMonitorBeacon();
35
+ expect(beacon).toContain("mulch prime");
36
+ });
37
+
38
+ test("contains startup instruction: legio status --json", () => {
39
+ const beacon = buildMonitorBeacon();
40
+ expect(beacon).toContain("legio status --json");
41
+ });
42
+
43
+ test("contains startup instruction: legio mail check --agent monitor", () => {
44
+ const beacon = buildMonitorBeacon();
45
+ expect(beacon).toContain("legio mail check --agent monitor");
46
+ });
47
+
48
+ test("contains startup instruction: patrol loop", () => {
49
+ const beacon = buildMonitorBeacon();
50
+ expect(beacon).toContain("patrol loop");
51
+ });
52
+
53
+ test("contains [LEGIO] prefix", () => {
54
+ const beacon = buildMonitorBeacon();
55
+ expect(beacon).toContain("[LEGIO]");
56
+ });
57
+
58
+ test("contains Depth: 0", () => {
59
+ const beacon = buildMonitorBeacon();
60
+ expect(beacon).toContain("Depth: 0");
61
+ });
62
+
63
+ test("contains Parent: none", () => {
64
+ const beacon = buildMonitorBeacon();
65
+ expect(beacon).toContain("Parent: none");
66
+ });
67
+ });
68
+
69
+ describe("monitorCommand", () => {
70
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
71
+ let stdoutWrites: string[] = [];
72
+
73
+ beforeEach(() => {
74
+ stdoutWrites = [];
75
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
76
+ stdoutWrites.push(String(chunk));
77
+ return true;
78
+ });
79
+ });
80
+
81
+ afterEach(() => {
82
+ stdoutSpy.mockRestore();
83
+ });
84
+
85
+ test("--help prints help text containing 'legio monitor'", async () => {
86
+ await monitorCommand(["--help"]);
87
+ const output = stdoutWrites.join("");
88
+ expect(output).toContain("legio monitor");
89
+ });
90
+
91
+ test("--help prints help text containing 'start'", async () => {
92
+ await monitorCommand(["--help"]);
93
+ const output = stdoutWrites.join("");
94
+ expect(output).toContain("start");
95
+ });
96
+
97
+ test("--help prints help text containing 'stop'", async () => {
98
+ await monitorCommand(["--help"]);
99
+ const output = stdoutWrites.join("");
100
+ expect(output).toContain("stop");
101
+ });
102
+
103
+ test("--help prints help text containing 'status'", async () => {
104
+ await monitorCommand(["--help"]);
105
+ const output = stdoutWrites.join("");
106
+ expect(output).toContain("status");
107
+ });
108
+
109
+ test("-h prints help text", async () => {
110
+ await monitorCommand(["-h"]);
111
+ const output = stdoutWrites.join("");
112
+ expect(output).toContain("legio monitor");
113
+ });
114
+
115
+ test("empty args [] shows help (same as --help)", async () => {
116
+ await monitorCommand([]);
117
+ const output = stdoutWrites.join("");
118
+ expect(output).toContain("legio monitor");
119
+ });
120
+
121
+ test("unknown subcommand 'restart' throws ValidationError", async () => {
122
+ await expect(monitorCommand(["restart"])).rejects.toThrow(ValidationError);
123
+ });
124
+
125
+ test("unknown subcommand error message contains the bad value 'restart'", async () => {
126
+ try {
127
+ await monitorCommand(["restart"]);
128
+ // Should not reach here
129
+ expect(true).toBe(false);
130
+ } catch (err) {
131
+ if (err instanceof ValidationError) {
132
+ expect(err.message).toContain("restart");
133
+ } else {
134
+ throw err;
135
+ }
136
+ }
137
+ });
138
+
139
+ test("start throws ValidationError with uid field when running as root", async () => {
140
+ const original = process.getuid;
141
+ process.getuid = () => 0;
142
+ try {
143
+ await expect(monitorCommand(["start"])).rejects.toMatchObject({
144
+ name: "ValidationError",
145
+ field: "uid",
146
+ });
147
+ } finally {
148
+ process.getuid = original;
149
+ }
150
+ });
151
+ });
@@ -0,0 +1,394 @@
1
+ /**
2
+ * CLI command: legio monitor start|stop|status
3
+ *
4
+ * Manages the persistent Tier 2 monitor agent lifecycle. The monitor runs
5
+ * at the project root (NOT in a worktree), continuously patrols the agent
6
+ * fleet, sends nudges to stalled agents, and reports health summaries to
7
+ * the coordinator.
8
+ *
9
+ * Unlike regular agents spawned by sling, the monitor:
10
+ * - Has no worktree (operates on the main working tree)
11
+ * - Has no bead assignment (it monitors, not implements)
12
+ * - Has no overlay CLAUDE.md (context comes via legio status + mail)
13
+ * - Persists across patrol cycles
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { deployHooks } from "../agents/hooks-deployer.ts";
20
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
21
+ import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
22
+ import { collectProviderEnv, loadConfig } from "../config.ts";
23
+ import { AgentError, isRunningAsRoot, ValidationError } from "../errors.ts";
24
+ import { openSessionStore } from "../sessions/compat.ts";
25
+ import type { AgentSession } from "../types.ts";
26
+ import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
27
+
28
+ /** Default monitor agent name. */
29
+ const MONITOR_NAME = "monitor";
30
+
31
+ async function fileExists(path: string): Promise<boolean> {
32
+ try {
33
+ await access(path);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Build the tmux session name for the monitor.
42
+ * Includes the project name to prevent cross-project collisions (legio-pcef).
43
+ */
44
+ function monitorTmuxSession(projectName: string): string {
45
+ return `legio-${projectName}-${MONITOR_NAME}`;
46
+ }
47
+
48
+ /**
49
+ * Build the monitor startup beacon — the first message sent to the monitor
50
+ * via tmux send-keys after Claude Code initializes.
51
+ */
52
+ export function buildMonitorBeacon(): string {
53
+ const timestamp = new Date().toISOString();
54
+ const parts = [
55
+ `[LEGIO] ${MONITOR_NAME} (monitor/tier-2) ${timestamp}`,
56
+ "Depth: 0 | Parent: none | Role: continuous fleet patrol",
57
+ `Startup: run mulch prime, check fleet (legio status --json), check mail (legio mail check --agent ${MONITOR_NAME}), then begin patrol loop`,
58
+ ];
59
+ return parts.join(" — ");
60
+ }
61
+
62
+ /**
63
+ * Determine whether to auto-attach to the tmux session after starting.
64
+ */
65
+ function resolveAttach(args: string[], isTTY: boolean): boolean {
66
+ if (args.includes("--attach")) return true;
67
+ if (args.includes("--no-attach")) return false;
68
+ return isTTY;
69
+ }
70
+
71
+ /**
72
+ * Start the monitor agent.
73
+ *
74
+ * 1. Verify no monitor is already running
75
+ * 2. Load config
76
+ * 3. Deploy hooks to project root's .claude/ (monitor-specific guards)
77
+ * 4. Create agent identity (if first time)
78
+ * 5. Spawn tmux session at project root with Claude Code
79
+ * 6. Send startup beacon
80
+ * 7. Record session in SessionStore (sessions.db)
81
+ */
82
+ async function startMonitor(args: string[]): Promise<void> {
83
+ if (isRunningAsRoot()) {
84
+ throw new ValidationError(
85
+ "legio must not run as root — agent processes execute arbitrary code",
86
+ {
87
+ field: "uid",
88
+ },
89
+ );
90
+ }
91
+
92
+ const json = args.includes("--json");
93
+ const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
94
+ const cwd = process.cwd();
95
+ const config = await loadConfig(cwd);
96
+ const projectRoot = config.project.root;
97
+ const tmuxSession = monitorTmuxSession(config.project.name);
98
+
99
+ // Check for existing monitor
100
+ const legioDir = join(projectRoot, ".legio");
101
+ const { store } = openSessionStore(legioDir);
102
+ try {
103
+ const existing = store.getByName(MONITOR_NAME);
104
+
105
+ if (
106
+ existing &&
107
+ existing.capability === "monitor" &&
108
+ existing.state !== "completed" &&
109
+ existing.state !== "zombie"
110
+ ) {
111
+ const alive = await isSessionAlive(existing.tmuxSession);
112
+ if (alive) {
113
+ throw new AgentError(
114
+ `Monitor is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
115
+ { agentName: MONITOR_NAME },
116
+ );
117
+ }
118
+ // Session recorded but tmux is dead — mark as completed and continue
119
+ store.updateState(MONITOR_NAME, "completed");
120
+ }
121
+
122
+ // Deploy monitor-specific hooks to the project root's .claude/ directory.
123
+ // The monitor gets the same structural enforcement as other non-implementation
124
+ // agents (Write/Edit/NotebookEdit blocked, dangerous bash commands blocked).
125
+ await deployHooks(projectRoot, MONITOR_NAME, "monitor");
126
+
127
+ // Create monitor identity if first run
128
+ const identityBaseDir = join(projectRoot, ".legio", "agents");
129
+ await mkdir(identityBaseDir, { recursive: true });
130
+ const existingIdentity = await loadIdentity(identityBaseDir, MONITOR_NAME);
131
+ if (!existingIdentity) {
132
+ await createIdentity(identityBaseDir, {
133
+ name: MONITOR_NAME,
134
+ capability: "monitor",
135
+ created: new Date().toISOString(),
136
+ sessionsCompleted: 0,
137
+ expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
138
+ recentTasks: [],
139
+ });
140
+ }
141
+
142
+ // Resolve model from config > manifest > fallback
143
+ const manifestLoader = createManifestLoader(
144
+ join(projectRoot, config.agents.manifestPath),
145
+ join(projectRoot, config.agents.baseDir),
146
+ );
147
+ const manifest = await manifestLoader.load();
148
+ const model = resolveModel(config, manifest, "monitor", "sonnet");
149
+
150
+ // Build settings JSON file to skip the bypass dialog and inject the
151
+ // agent definition. Avoids --append-system-prompt's ERR_STREAM_DESTROYED
152
+ // crash with large payloads on Claude Code v2.1.50.
153
+ const agentDefPath = join(projectRoot, ".legio", "agent-defs", "monitor.md");
154
+ const legioDir = join(projectRoot, ".legio");
155
+ const settings: Record<string, unknown> = { skipDangerousModePermissionPrompt: true };
156
+ if (await fileExists(agentDefPath)) {
157
+ settings.appendSystemPrompt = await readFile(agentDefPath, "utf-8");
158
+ }
159
+ const settingsPath = join(legioDir, `settings-${MONITOR_NAME}.json`);
160
+ await writeFile(settingsPath, JSON.stringify(settings), "utf-8");
161
+ const claudeCmd = `claude --model ${model} --dangerously-skip-permissions --settings ${settingsPath}`;
162
+ const pid = await createSession(tmuxSession, projectRoot, claudeCmd, {
163
+ ...collectProviderEnv(),
164
+ LEGIO_AGENT_NAME: MONITOR_NAME,
165
+ });
166
+
167
+ // Record session BEFORE sending the beacon so that hook-triggered
168
+ // updateLastActivity() can find the entry and transition booting->working.
169
+ const session: AgentSession = {
170
+ id: `session-${Date.now()}-${MONITOR_NAME}`,
171
+ agentName: MONITOR_NAME,
172
+ capability: "monitor",
173
+ worktreePath: projectRoot, // Monitor uses project root, not a worktree
174
+ branchName: config.project.canonicalBranch, // Operates on canonical branch
175
+ beadId: "", // No specific bead assignment
176
+ tmuxSession,
177
+ state: "booting",
178
+ pid,
179
+ parentAgent: null, // Top of hierarchy (alongside coordinator)
180
+ depth: 0,
181
+ runId: null,
182
+ startedAt: new Date().toISOString(),
183
+ lastActivity: new Date().toISOString(),
184
+ escalationLevel: 0,
185
+ stalledSince: null,
186
+ };
187
+
188
+ store.upsert(session);
189
+
190
+ // Send beacon after TUI initialization delay
191
+ await new Promise((resolve) => setTimeout(resolve, 3_000));
192
+ const beacon = buildMonitorBeacon();
193
+ await sendKeys(tmuxSession, beacon);
194
+
195
+ // Follow-up Enter to ensure submission (same pattern as sling.ts)
196
+ await new Promise((resolve) => setTimeout(resolve, 500));
197
+ await sendKeys(tmuxSession, "");
198
+
199
+ const output = {
200
+ agentName: MONITOR_NAME,
201
+ capability: "monitor",
202
+ tmuxSession,
203
+ projectRoot,
204
+ pid,
205
+ };
206
+
207
+ if (json) {
208
+ process.stdout.write(`${JSON.stringify(output)}\n`);
209
+ } else {
210
+ process.stdout.write("Monitor started\n");
211
+ process.stdout.write(` Tmux: ${tmuxSession}\n`);
212
+ process.stdout.write(` Root: ${projectRoot}\n`);
213
+ process.stdout.write(` PID: ${pid}\n`);
214
+ }
215
+
216
+ if (shouldAttach) {
217
+ spawnSync("tmux", ["attach-session", "-t", tmuxSession], {
218
+ stdio: ["inherit", "inherit", "inherit"],
219
+ });
220
+ }
221
+ } finally {
222
+ store.close();
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Stop the monitor agent.
228
+ *
229
+ * 1. Find the active monitor session
230
+ * 2. Kill the tmux session (with process tree cleanup)
231
+ * 3. Mark session as completed in SessionStore
232
+ */
233
+ async function stopMonitor(args: string[]): Promise<void> {
234
+ const json = args.includes("--json");
235
+ const cwd = process.cwd();
236
+ const config = await loadConfig(cwd);
237
+ const projectRoot = config.project.root;
238
+
239
+ const legioDir = join(projectRoot, ".legio");
240
+ const { store } = openSessionStore(legioDir);
241
+ try {
242
+ const session = store.getByName(MONITOR_NAME);
243
+
244
+ if (
245
+ !session ||
246
+ session.capability !== "monitor" ||
247
+ session.state === "completed" ||
248
+ session.state === "zombie"
249
+ ) {
250
+ throw new AgentError("No active monitor session found", {
251
+ agentName: MONITOR_NAME,
252
+ });
253
+ }
254
+
255
+ // Kill tmux session with process tree cleanup
256
+ const alive = await isSessionAlive(session.tmuxSession);
257
+ if (alive) {
258
+ await killSession(session.tmuxSession);
259
+ }
260
+
261
+ // Update session state
262
+ store.updateState(MONITOR_NAME, "completed");
263
+ store.updateLastActivity(MONITOR_NAME);
264
+
265
+ if (json) {
266
+ process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
267
+ } else {
268
+ process.stdout.write(`Monitor stopped (session: ${session.id})\n`);
269
+ }
270
+ } finally {
271
+ store.close();
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Show monitor status.
277
+ *
278
+ * Checks session registry and tmux liveness to report actual state.
279
+ */
280
+ async function statusMonitor(args: string[]): Promise<void> {
281
+ const json = args.includes("--json");
282
+ const cwd = process.cwd();
283
+ const config = await loadConfig(cwd);
284
+ const projectRoot = config.project.root;
285
+
286
+ const legioDir = join(projectRoot, ".legio");
287
+ const { store } = openSessionStore(legioDir);
288
+ try {
289
+ const session = store.getByName(MONITOR_NAME);
290
+
291
+ if (
292
+ !session ||
293
+ session.capability !== "monitor" ||
294
+ session.state === "completed" ||
295
+ session.state === "zombie"
296
+ ) {
297
+ if (json) {
298
+ process.stdout.write(`${JSON.stringify({ running: false })}\n`);
299
+ } else {
300
+ process.stdout.write("Monitor is not running\n");
301
+ }
302
+ return;
303
+ }
304
+
305
+ const alive = await isSessionAlive(session.tmuxSession);
306
+
307
+ // Reconcile state: if session says active but tmux is dead, update.
308
+ // We already filtered out completed/zombie states above, so if tmux is dead
309
+ // this session needs to be marked as zombie.
310
+ if (!alive) {
311
+ store.updateState(MONITOR_NAME, "zombie");
312
+ store.updateLastActivity(MONITOR_NAME);
313
+ session.state = "zombie";
314
+ }
315
+
316
+ const status = {
317
+ running: alive,
318
+ sessionId: session.id,
319
+ state: session.state,
320
+ tmuxSession: session.tmuxSession,
321
+ pid: session.pid,
322
+ startedAt: session.startedAt,
323
+ lastActivity: session.lastActivity,
324
+ };
325
+
326
+ if (json) {
327
+ process.stdout.write(`${JSON.stringify(status)}\n`);
328
+ } else {
329
+ const stateLabel = alive ? "running" : session.state;
330
+ process.stdout.write(`Monitor: ${stateLabel}\n`);
331
+ process.stdout.write(` Session: ${session.id}\n`);
332
+ process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
333
+ process.stdout.write(` PID: ${session.pid}\n`);
334
+ process.stdout.write(` Started: ${session.startedAt}\n`);
335
+ process.stdout.write(` Activity: ${session.lastActivity}\n`);
336
+ }
337
+ } finally {
338
+ store.close();
339
+ }
340
+ }
341
+
342
+ const MONITOR_HELP = `legio monitor — Manage the persistent Tier 2 monitor agent
343
+
344
+ Usage: legio monitor <subcommand> [flags]
345
+
346
+ Subcommands:
347
+ start Start the monitor (spawns Claude Code at project root)
348
+ stop Stop the monitor (kills tmux session)
349
+ status Show monitor state
350
+
351
+ Start options:
352
+ --attach Always attach to tmux session after start
353
+ --no-attach Never attach to tmux session after start
354
+ Default: attach when running in an interactive TTY
355
+
356
+ General options:
357
+ --json Output as JSON
358
+ --help, -h Show this help
359
+
360
+ The monitor agent (Tier 2) continuously patrols the agent fleet by:
361
+ - Checking agent health via legio status
362
+ - Sending progressive nudges to stalled agents
363
+ - Escalating unresponsive agents to the coordinator
364
+ - Producing periodic health summaries`;
365
+
366
+ /**
367
+ * Entry point for `legio monitor <subcommand>`.
368
+ */
369
+ export async function monitorCommand(args: string[]): Promise<void> {
370
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
371
+ process.stdout.write(`${MONITOR_HELP}\n`);
372
+ return;
373
+ }
374
+
375
+ const subcommand = args[0];
376
+ const subArgs = args.slice(1);
377
+
378
+ switch (subcommand) {
379
+ case "start":
380
+ await startMonitor(subArgs);
381
+ break;
382
+ case "stop":
383
+ await stopMonitor(subArgs);
384
+ break;
385
+ case "status":
386
+ await statusMonitor(subArgs);
387
+ break;
388
+ default:
389
+ throw new ValidationError(
390
+ `Unknown monitor subcommand: ${subcommand}. Run 'legio monitor --help' for usage.`,
391
+ { field: "subcommand", value: subcommand },
392
+ );
393
+ }
394
+ }