@katyella/legio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,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
|
+
}
|