@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -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 +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,191 @@
|
|
|
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, spyOn, test } from "bun:test";
|
|
10
|
+
import { mkdir } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
13
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
14
|
+
import { buildMonitorBeacon, monitorCommand } from "./monitor.ts";
|
|
15
|
+
import { isRunningAsRoot } from "./sling.ts";
|
|
16
|
+
|
|
17
|
+
describe("buildMonitorBeacon", () => {
|
|
18
|
+
test("contains monitor agent name", () => {
|
|
19
|
+
const beacon = buildMonitorBeacon();
|
|
20
|
+
expect(beacon).toContain("monitor");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("contains tier-2 designation", () => {
|
|
24
|
+
const beacon = buildMonitorBeacon();
|
|
25
|
+
expect(beacon).toContain("tier-2");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("contains ISO timestamp with today's date", () => {
|
|
29
|
+
const beacon = buildMonitorBeacon();
|
|
30
|
+
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
31
|
+
if (!today) {
|
|
32
|
+
throw new Error("Failed to extract date from ISO string");
|
|
33
|
+
}
|
|
34
|
+
expect(beacon).toContain(today);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("contains startup instruction: mulch prime", () => {
|
|
38
|
+
const beacon = buildMonitorBeacon();
|
|
39
|
+
expect(beacon).toContain("mulch prime");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("contains startup instruction: overstory status --json", () => {
|
|
43
|
+
const beacon = buildMonitorBeacon();
|
|
44
|
+
expect(beacon).toContain("overstory status --json");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("contains startup instruction: overstory mail check --agent monitor", () => {
|
|
48
|
+
const beacon = buildMonitorBeacon();
|
|
49
|
+
expect(beacon).toContain("overstory mail check --agent monitor");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("contains startup instruction: patrol loop", () => {
|
|
53
|
+
const beacon = buildMonitorBeacon();
|
|
54
|
+
expect(beacon).toContain("patrol loop");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("contains [OVERSTORY] prefix", () => {
|
|
58
|
+
const beacon = buildMonitorBeacon();
|
|
59
|
+
expect(beacon).toContain("[OVERSTORY]");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("contains Depth: 0", () => {
|
|
63
|
+
const beacon = buildMonitorBeacon();
|
|
64
|
+
expect(beacon).toContain("Depth: 0");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("contains Parent: none", () => {
|
|
68
|
+
const beacon = buildMonitorBeacon();
|
|
69
|
+
expect(beacon).toContain("Parent: none");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("monitorCommand", () => {
|
|
74
|
+
let stdoutSpy: ReturnType<typeof spyOn>;
|
|
75
|
+
let stdoutWrites: string[] = [];
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
stdoutWrites = [];
|
|
79
|
+
stdoutSpy = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
80
|
+
stdoutWrites.push(String(chunk));
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
stdoutSpy.mockRestore();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("--help prints help text containing 'overstory monitor'", async () => {
|
|
90
|
+
await monitorCommand(["--help"]);
|
|
91
|
+
const output = stdoutWrites.join("");
|
|
92
|
+
expect(output).toContain("overstory monitor");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("--help prints help text containing 'start'", async () => {
|
|
96
|
+
await monitorCommand(["--help"]);
|
|
97
|
+
const output = stdoutWrites.join("");
|
|
98
|
+
expect(output).toContain("start");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("--help prints help text containing 'stop'", async () => {
|
|
102
|
+
await monitorCommand(["--help"]);
|
|
103
|
+
const output = stdoutWrites.join("");
|
|
104
|
+
expect(output).toContain("stop");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("--help prints help text containing 'status'", async () => {
|
|
108
|
+
await monitorCommand(["--help"]);
|
|
109
|
+
const output = stdoutWrites.join("");
|
|
110
|
+
expect(output).toContain("status");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("-h prints help text", async () => {
|
|
114
|
+
await monitorCommand(["-h"]);
|
|
115
|
+
const output = stdoutWrites.join("");
|
|
116
|
+
expect(output).toContain("overstory monitor");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("empty args [] shows help (same as --help)", async () => {
|
|
120
|
+
await monitorCommand([]);
|
|
121
|
+
const output = stdoutWrites.join("");
|
|
122
|
+
expect(output).toContain("overstory monitor");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("unknown subcommand 'restart' throws ValidationError", async () => {
|
|
126
|
+
await expect(monitorCommand(["restart"])).rejects.toThrow(ValidationError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("unknown subcommand error message contains the bad value 'restart'", async () => {
|
|
130
|
+
try {
|
|
131
|
+
await monitorCommand(["restart"]);
|
|
132
|
+
// Should not reach here
|
|
133
|
+
expect(true).toBe(false);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof ValidationError) {
|
|
136
|
+
expect(err.message).toContain("restart");
|
|
137
|
+
} else {
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("tier2Enabled gate", () => {
|
|
144
|
+
let tempDir: string;
|
|
145
|
+
const originalCwd = process.cwd();
|
|
146
|
+
|
|
147
|
+
beforeEach(async () => {
|
|
148
|
+
process.chdir(originalCwd);
|
|
149
|
+
tempDir = await createTempGitRepo();
|
|
150
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
151
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
152
|
+
// Write minimal config — tier2Enabled defaults to false
|
|
153
|
+
await Bun.write(
|
|
154
|
+
join(overstoryDir, "config.yaml"),
|
|
155
|
+
["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
|
|
156
|
+
"\n",
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
process.chdir(tempDir);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterEach(async () => {
|
|
163
|
+
process.chdir(originalCwd);
|
|
164
|
+
await cleanupTempDir(tempDir);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("monitor start throws AgentError when tier2Enabled is false (default)", async () => {
|
|
168
|
+
await expect(monitorCommand(["start"])).rejects.toThrow(AgentError);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("monitor start error message contains 'disabled' when tier2Enabled is false", async () => {
|
|
172
|
+
try {
|
|
173
|
+
await monitorCommand(["start"]);
|
|
174
|
+
expect(true).toBe(false); // Should not reach here
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err instanceof AgentError) {
|
|
177
|
+
expect(err.message).toContain("disabled");
|
|
178
|
+
} else {
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("isRunningAsRoot (imported from sling)", () => {
|
|
187
|
+
test("is accessible from monitor test file", () => {
|
|
188
|
+
expect(isRunningAsRoot(() => 0)).toBe(true);
|
|
189
|
+
expect(isRunningAsRoot(() => 1000)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory 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 overstory status + mail)
|
|
13
|
+
* - Persists across patrol cycles
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { deployHooks } from "../agents/hooks-deployer.ts";
|
|
19
|
+
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
20
|
+
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
21
|
+
import { loadConfig } from "../config.ts";
|
|
22
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
23
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
24
|
+
import type { AgentSession } from "../types.ts";
|
|
25
|
+
import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
|
|
26
|
+
import { isRunningAsRoot } from "./sling.ts";
|
|
27
|
+
|
|
28
|
+
/** Default monitor agent name. */
|
|
29
|
+
const MONITOR_NAME = "monitor";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the tmux session name for the monitor.
|
|
33
|
+
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
34
|
+
*/
|
|
35
|
+
function monitorTmuxSession(projectName: string): string {
|
|
36
|
+
return `overstory-${projectName}-${MONITOR_NAME}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the monitor startup beacon — the first message sent to the monitor
|
|
41
|
+
* via tmux send-keys after Claude Code initializes.
|
|
42
|
+
*/
|
|
43
|
+
export function buildMonitorBeacon(): string {
|
|
44
|
+
const timestamp = new Date().toISOString();
|
|
45
|
+
const parts = [
|
|
46
|
+
`[OVERSTORY] ${MONITOR_NAME} (monitor/tier-2) ${timestamp}`,
|
|
47
|
+
"Depth: 0 | Parent: none | Role: continuous fleet patrol",
|
|
48
|
+
`Startup: run mulch prime, check fleet (overstory status --json), check mail (overstory mail check --agent ${MONITOR_NAME}), then begin patrol loop`,
|
|
49
|
+
];
|
|
50
|
+
return parts.join(" — ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Determine whether to auto-attach to the tmux session after starting.
|
|
55
|
+
*/
|
|
56
|
+
function resolveAttach(args: string[], isTTY: boolean): boolean {
|
|
57
|
+
if (args.includes("--attach")) return true;
|
|
58
|
+
if (args.includes("--no-attach")) return false;
|
|
59
|
+
return isTTY;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start the monitor agent.
|
|
64
|
+
*
|
|
65
|
+
* 1. Verify no monitor is already running
|
|
66
|
+
* 2. Load config
|
|
67
|
+
* 3. Deploy hooks to project root's .claude/ (monitor-specific guards)
|
|
68
|
+
* 4. Create agent identity (if first time)
|
|
69
|
+
* 5. Spawn tmux session at project root with Claude Code
|
|
70
|
+
* 6. Send startup beacon
|
|
71
|
+
* 7. Record session in SessionStore (sessions.db)
|
|
72
|
+
*/
|
|
73
|
+
async function startMonitor(args: string[]): Promise<void> {
|
|
74
|
+
const json = args.includes("--json");
|
|
75
|
+
const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
|
|
76
|
+
|
|
77
|
+
if (isRunningAsRoot()) {
|
|
78
|
+
throw new AgentError(
|
|
79
|
+
"Cannot spawn agents as root (UID 0). The claude CLI rejects --dangerously-skip-permissions when run as root, causing the tmux session to die immediately. Run overstory as a non-root user.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cwd = process.cwd();
|
|
84
|
+
const config = await loadConfig(cwd);
|
|
85
|
+
|
|
86
|
+
// Gate on tier2Enabled config flag
|
|
87
|
+
if (!config.watchdog.tier2Enabled) {
|
|
88
|
+
throw new AgentError(
|
|
89
|
+
"Monitor agent (Tier 2) is disabled. Set watchdog.tier2Enabled: true in .overstory/config.yaml to enable.",
|
|
90
|
+
{ agentName: MONITOR_NAME },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const projectRoot = config.project.root;
|
|
95
|
+
const tmuxSession = monitorTmuxSession(config.project.name);
|
|
96
|
+
|
|
97
|
+
// Check for existing monitor
|
|
98
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
99
|
+
const { store } = openSessionStore(overstoryDir);
|
|
100
|
+
try {
|
|
101
|
+
const existing = store.getByName(MONITOR_NAME);
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
existing &&
|
|
105
|
+
existing.capability === "monitor" &&
|
|
106
|
+
existing.state !== "completed" &&
|
|
107
|
+
existing.state !== "zombie"
|
|
108
|
+
) {
|
|
109
|
+
const alive = await isSessionAlive(existing.tmuxSession);
|
|
110
|
+
if (alive) {
|
|
111
|
+
throw new AgentError(
|
|
112
|
+
`Monitor is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
|
|
113
|
+
{ agentName: MONITOR_NAME },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
// Session recorded but tmux is dead — mark as completed and continue
|
|
117
|
+
store.updateState(MONITOR_NAME, "completed");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Deploy monitor-specific hooks to the project root's .claude/ directory.
|
|
121
|
+
// The monitor gets the same structural enforcement as other non-implementation
|
|
122
|
+
// agents (Write/Edit/NotebookEdit blocked, dangerous bash commands blocked).
|
|
123
|
+
await deployHooks(projectRoot, MONITOR_NAME, "monitor");
|
|
124
|
+
|
|
125
|
+
// Create monitor identity if first run
|
|
126
|
+
const identityBaseDir = join(projectRoot, ".overstory", "agents");
|
|
127
|
+
await mkdir(identityBaseDir, { recursive: true });
|
|
128
|
+
const existingIdentity = await loadIdentity(identityBaseDir, MONITOR_NAME);
|
|
129
|
+
if (!existingIdentity) {
|
|
130
|
+
await createIdentity(identityBaseDir, {
|
|
131
|
+
name: MONITOR_NAME,
|
|
132
|
+
capability: "monitor",
|
|
133
|
+
created: new Date().toISOString(),
|
|
134
|
+
sessionsCompleted: 0,
|
|
135
|
+
expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
|
|
136
|
+
recentTasks: [],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolve model from config > manifest > fallback
|
|
141
|
+
const manifestLoader = createManifestLoader(
|
|
142
|
+
join(projectRoot, config.agents.manifestPath),
|
|
143
|
+
join(projectRoot, config.agents.baseDir),
|
|
144
|
+
);
|
|
145
|
+
const manifest = await manifestLoader.load();
|
|
146
|
+
const { model, env } = resolveModel(config, manifest, "monitor", "sonnet");
|
|
147
|
+
|
|
148
|
+
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
149
|
+
// Inject the monitor base definition via --append-system-prompt.
|
|
150
|
+
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "monitor.md");
|
|
151
|
+
const agentDefFile = Bun.file(agentDefPath);
|
|
152
|
+
let claudeCmd = `claude --model ${model} --dangerously-skip-permissions`;
|
|
153
|
+
if (await agentDefFile.exists()) {
|
|
154
|
+
const agentDef = await agentDefFile.text();
|
|
155
|
+
const escaped = agentDef.replace(/'/g, "'\\''");
|
|
156
|
+
claudeCmd += ` --append-system-prompt '${escaped}'`;
|
|
157
|
+
}
|
|
158
|
+
const pid = await createSession(tmuxSession, projectRoot, claudeCmd, {
|
|
159
|
+
...env,
|
|
160
|
+
OVERSTORY_AGENT_NAME: MONITOR_NAME,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Record session BEFORE sending the beacon so that hook-triggered
|
|
164
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
165
|
+
const session: AgentSession = {
|
|
166
|
+
id: `session-${Date.now()}-${MONITOR_NAME}`,
|
|
167
|
+
agentName: MONITOR_NAME,
|
|
168
|
+
capability: "monitor",
|
|
169
|
+
worktreePath: projectRoot, // Monitor uses project root, not a worktree
|
|
170
|
+
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
171
|
+
beadId: "", // No specific bead assignment
|
|
172
|
+
tmuxSession,
|
|
173
|
+
state: "booting",
|
|
174
|
+
pid,
|
|
175
|
+
parentAgent: null, // Top of hierarchy (alongside coordinator)
|
|
176
|
+
depth: 0,
|
|
177
|
+
runId: null,
|
|
178
|
+
startedAt: new Date().toISOString(),
|
|
179
|
+
lastActivity: new Date().toISOString(),
|
|
180
|
+
escalationLevel: 0,
|
|
181
|
+
stalledSince: null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
store.upsert(session);
|
|
185
|
+
|
|
186
|
+
// Send beacon after TUI initialization delay
|
|
187
|
+
await Bun.sleep(3_000);
|
|
188
|
+
const beacon = buildMonitorBeacon();
|
|
189
|
+
await sendKeys(tmuxSession, beacon);
|
|
190
|
+
|
|
191
|
+
// Follow-up Enter to ensure submission (same pattern as sling.ts)
|
|
192
|
+
await Bun.sleep(500);
|
|
193
|
+
await sendKeys(tmuxSession, "");
|
|
194
|
+
|
|
195
|
+
const output = {
|
|
196
|
+
agentName: MONITOR_NAME,
|
|
197
|
+
capability: "monitor",
|
|
198
|
+
tmuxSession,
|
|
199
|
+
projectRoot,
|
|
200
|
+
pid,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (json) {
|
|
204
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
205
|
+
} else {
|
|
206
|
+
process.stdout.write("Monitor started\n");
|
|
207
|
+
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
208
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
209
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (shouldAttach) {
|
|
213
|
+
Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
|
|
214
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
store.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Stop the monitor agent.
|
|
224
|
+
*
|
|
225
|
+
* 1. Find the active monitor session
|
|
226
|
+
* 2. Kill the tmux session (with process tree cleanup)
|
|
227
|
+
* 3. Mark session as completed in SessionStore
|
|
228
|
+
*/
|
|
229
|
+
async function stopMonitor(args: string[]): Promise<void> {
|
|
230
|
+
const json = args.includes("--json");
|
|
231
|
+
const cwd = process.cwd();
|
|
232
|
+
const config = await loadConfig(cwd);
|
|
233
|
+
const projectRoot = config.project.root;
|
|
234
|
+
|
|
235
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
236
|
+
const { store } = openSessionStore(overstoryDir);
|
|
237
|
+
try {
|
|
238
|
+
const session = store.getByName(MONITOR_NAME);
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
!session ||
|
|
242
|
+
session.capability !== "monitor" ||
|
|
243
|
+
session.state === "completed" ||
|
|
244
|
+
session.state === "zombie"
|
|
245
|
+
) {
|
|
246
|
+
throw new AgentError("No active monitor session found", {
|
|
247
|
+
agentName: MONITOR_NAME,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Kill tmux session with process tree cleanup
|
|
252
|
+
const alive = await isSessionAlive(session.tmuxSession);
|
|
253
|
+
if (alive) {
|
|
254
|
+
await killSession(session.tmuxSession);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Update session state
|
|
258
|
+
store.updateState(MONITOR_NAME, "completed");
|
|
259
|
+
store.updateLastActivity(MONITOR_NAME);
|
|
260
|
+
|
|
261
|
+
if (json) {
|
|
262
|
+
process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
|
|
263
|
+
} else {
|
|
264
|
+
process.stdout.write(`Monitor stopped (session: ${session.id})\n`);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
store.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Show monitor status.
|
|
273
|
+
*
|
|
274
|
+
* Checks session registry and tmux liveness to report actual state.
|
|
275
|
+
*/
|
|
276
|
+
async function statusMonitor(args: string[]): Promise<void> {
|
|
277
|
+
const json = args.includes("--json");
|
|
278
|
+
const cwd = process.cwd();
|
|
279
|
+
const config = await loadConfig(cwd);
|
|
280
|
+
const projectRoot = config.project.root;
|
|
281
|
+
|
|
282
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
283
|
+
const { store } = openSessionStore(overstoryDir);
|
|
284
|
+
try {
|
|
285
|
+
const session = store.getByName(MONITOR_NAME);
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
!session ||
|
|
289
|
+
session.capability !== "monitor" ||
|
|
290
|
+
session.state === "completed" ||
|
|
291
|
+
session.state === "zombie"
|
|
292
|
+
) {
|
|
293
|
+
if (json) {
|
|
294
|
+
process.stdout.write(`${JSON.stringify({ running: false })}\n`);
|
|
295
|
+
} else {
|
|
296
|
+
process.stdout.write("Monitor is not running\n");
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const alive = await isSessionAlive(session.tmuxSession);
|
|
302
|
+
|
|
303
|
+
// Reconcile state: if session says active but tmux is dead, update.
|
|
304
|
+
// We already filtered out completed/zombie states above, so if tmux is dead
|
|
305
|
+
// this session needs to be marked as zombie.
|
|
306
|
+
if (!alive) {
|
|
307
|
+
store.updateState(MONITOR_NAME, "zombie");
|
|
308
|
+
store.updateLastActivity(MONITOR_NAME);
|
|
309
|
+
session.state = "zombie";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const status = {
|
|
313
|
+
running: alive,
|
|
314
|
+
sessionId: session.id,
|
|
315
|
+
state: session.state,
|
|
316
|
+
tmuxSession: session.tmuxSession,
|
|
317
|
+
pid: session.pid,
|
|
318
|
+
startedAt: session.startedAt,
|
|
319
|
+
lastActivity: session.lastActivity,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (json) {
|
|
323
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
324
|
+
} else {
|
|
325
|
+
const stateLabel = alive ? "running" : session.state;
|
|
326
|
+
process.stdout.write(`Monitor: ${stateLabel}\n`);
|
|
327
|
+
process.stdout.write(` Session: ${session.id}\n`);
|
|
328
|
+
process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
|
|
329
|
+
process.stdout.write(` PID: ${session.pid}\n`);
|
|
330
|
+
process.stdout.write(` Started: ${session.startedAt}\n`);
|
|
331
|
+
process.stdout.write(` Activity: ${session.lastActivity}\n`);
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
store.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const MONITOR_HELP = `overstory monitor — Manage the persistent Tier 2 monitor agent
|
|
339
|
+
|
|
340
|
+
Usage: overstory monitor <subcommand> [flags]
|
|
341
|
+
|
|
342
|
+
Subcommands:
|
|
343
|
+
start Start the monitor (spawns Claude Code at project root)
|
|
344
|
+
stop Stop the monitor (kills tmux session)
|
|
345
|
+
status Show monitor state
|
|
346
|
+
|
|
347
|
+
Start options:
|
|
348
|
+
--attach Always attach to tmux session after start
|
|
349
|
+
--no-attach Never attach to tmux session after start
|
|
350
|
+
Default: attach when running in an interactive TTY
|
|
351
|
+
|
|
352
|
+
General options:
|
|
353
|
+
--json Output as JSON
|
|
354
|
+
--help, -h Show this help
|
|
355
|
+
|
|
356
|
+
The monitor agent (Tier 2) continuously patrols the agent fleet by:
|
|
357
|
+
- Checking agent health via overstory status
|
|
358
|
+
- Sending progressive nudges to stalled agents
|
|
359
|
+
- Escalating unresponsive agents to the coordinator
|
|
360
|
+
- Producing periodic health summaries`;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Entry point for `overstory monitor <subcommand>`.
|
|
364
|
+
*/
|
|
365
|
+
export async function monitorCommand(args: string[]): Promise<void> {
|
|
366
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
367
|
+
process.stdout.write(`${MONITOR_HELP}\n`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const subcommand = args[0];
|
|
372
|
+
const subArgs = args.slice(1);
|
|
373
|
+
|
|
374
|
+
switch (subcommand) {
|
|
375
|
+
case "start":
|
|
376
|
+
await startMonitor(subArgs);
|
|
377
|
+
break;
|
|
378
|
+
case "stop":
|
|
379
|
+
await stopMonitor(subArgs);
|
|
380
|
+
break;
|
|
381
|
+
case "status":
|
|
382
|
+
await statusMonitor(subArgs);
|
|
383
|
+
break;
|
|
384
|
+
default:
|
|
385
|
+
throw new ValidationError(
|
|
386
|
+
`Unknown monitor subcommand: ${subcommand}. Run 'overstory monitor --help' for usage.`,
|
|
387
|
+
{ field: "subcommand", value: subcommand },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|