@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,1556 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+
7
+ /** Test helper: create a file accessor for checking existence and reading content. */
8
+ function fileAccessor(path: string) {
9
+ return {
10
+ exists: () =>
11
+ access(path).then(
12
+ () => true,
13
+ () => false,
14
+ ),
15
+ text: () => readFile(path, "utf-8"),
16
+ };
17
+ }
18
+
19
+ import { ValidationError } from "../errors.ts";
20
+ import { createEventStore } from "../events/store.ts";
21
+ import { createMailClient } from "../mail/client.ts";
22
+ import { createMailStore } from "../mail/store.ts";
23
+ import { createMetricsStore } from "../metrics/store.ts";
24
+ import type { MulchClient } from "../mulch/client.ts";
25
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
26
+ import type { AgentSession, MulchLearnResult, StoredEvent } from "../types.ts";
27
+ import { autoRecordExpertise, logCommand } from "./log.ts";
28
+
29
+ /**
30
+ * Tests for `legio log` command.
31
+ *
32
+ * Uses real filesystem (temp dirs) and real better-sqlite3 to test logging behavior.
33
+ * Captures process.stdout.write to verify help text output.
34
+ */
35
+
36
+ describe("logCommand", () => {
37
+ let chunks: string[];
38
+ let originalWrite: typeof process.stdout.write;
39
+ let tempDir: string;
40
+ let originalCwd: string;
41
+
42
+ beforeEach(async () => {
43
+ // Spy on stdout
44
+ chunks = [];
45
+ originalWrite = process.stdout.write;
46
+ process.stdout.write = ((chunk: string) => {
47
+ chunks.push(chunk);
48
+ return true;
49
+ }) as typeof process.stdout.write;
50
+
51
+ // Create temp dir with .legio/config.yaml structure
52
+ tempDir = await mkdtemp(join(tmpdir(), "log-test-"));
53
+ const legioDir = join(tempDir, ".legio");
54
+ await mkdir(legioDir, { recursive: true });
55
+ await writeFile(
56
+ join(legioDir, "config.yaml"),
57
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
58
+ );
59
+
60
+ // Change to temp dir so loadConfig() works
61
+ originalCwd = process.cwd();
62
+ process.chdir(tempDir);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ process.stdout.write = originalWrite;
67
+ process.chdir(originalCwd);
68
+ // Retry cleanup: SQLite WAL checkpoint may still be flushing on Linux CI
69
+ for (let i = 0; i < 3; i++) {
70
+ try {
71
+ await rm(tempDir, { recursive: true, force: true });
72
+ break;
73
+ } catch {
74
+ if (i < 2) await new Promise((r) => setTimeout(r, 50));
75
+ }
76
+ }
77
+ });
78
+
79
+ function output(): string {
80
+ return chunks.join("");
81
+ }
82
+
83
+ /**
84
+ * Fake MulchClient for testing autoRecordExpertise.
85
+ * Only learn() and record() are implemented — other methods are stubs.
86
+ * Justified: we are testing orchestration logic, not the mulch CLI itself.
87
+ */
88
+ function createFakeMulchClient(
89
+ learnResult: MulchLearnResult,
90
+ opts?: { recordShouldFail?: boolean },
91
+ ): {
92
+ client: MulchClient;
93
+ recordCalls: Array<{ domain: string; options: Record<string, unknown> }>;
94
+ } {
95
+ const recordCalls: Array<{ domain: string; options: Record<string, unknown> }> = [];
96
+ const client = {
97
+ async learn() {
98
+ return learnResult;
99
+ },
100
+ async record(domain: string, options: Record<string, unknown>) {
101
+ if (opts?.recordShouldFail) {
102
+ throw new Error("mulch record failed");
103
+ }
104
+ recordCalls.push({ domain, options });
105
+ },
106
+ } as unknown as MulchClient;
107
+ return { client, recordCalls };
108
+ }
109
+
110
+ test("--help flag shows help text", async () => {
111
+ await logCommand(["--help"]);
112
+ const out = output();
113
+
114
+ expect(out).toContain("legio log");
115
+ expect(out).toContain("tool-start");
116
+ expect(out).toContain("tool-end");
117
+ expect(out).toContain("session-end");
118
+ expect(out).toContain("--agent");
119
+ });
120
+
121
+ test("-h flag shows help text", async () => {
122
+ await logCommand(["-h"]);
123
+ const out = output();
124
+
125
+ expect(out).toContain("legio log");
126
+ expect(out).toContain("tool-start");
127
+ expect(out).toContain("tool-end");
128
+ expect(out).toContain("session-end");
129
+ expect(out).toContain("--agent");
130
+ });
131
+
132
+ test("missing event with only flags throws ValidationError", async () => {
133
+ // The code finds first non-flag arg. Passing only flags should trigger "Event is required"
134
+ // Note: the implementation checks for undefined event
135
+ await expect(logCommand([])).rejects.toThrow(ValidationError);
136
+ await expect(logCommand([])).rejects.toThrow("Event is required");
137
+ });
138
+
139
+ test("invalid event name throws ValidationError", async () => {
140
+ await expect(logCommand(["invalid-event", "--agent", "test-agent"])).rejects.toThrow(
141
+ ValidationError,
142
+ );
143
+ await expect(logCommand(["invalid-event", "--agent", "test-agent"])).rejects.toThrow(
144
+ "Invalid event",
145
+ );
146
+ });
147
+
148
+ test("missing --agent flag throws ValidationError", async () => {
149
+ await expect(logCommand(["tool-start"])).rejects.toThrow(ValidationError);
150
+ await expect(logCommand(["tool-start"])).rejects.toThrow("--agent is required");
151
+ });
152
+
153
+ test("tool-start creates log directory structure", async () => {
154
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Read"]);
155
+
156
+ const logsDir = join(tempDir, ".legio", "logs", "test-builder");
157
+ const contents = await readdir(logsDir);
158
+
159
+ // Should have at least .current-session marker and a session directory
160
+ expect(contents).toContain(".current-session");
161
+ expect(contents.length).toBeGreaterThanOrEqual(2);
162
+ });
163
+
164
+ test("tool-start creates session directory and .current-session marker", async () => {
165
+ await logCommand(["tool-start", "--agent", "test-scout", "--tool-name", "Grep"]);
166
+
167
+ const logsDir = join(tempDir, ".legio", "logs", "test-scout");
168
+ const markerPath = join(logsDir, ".current-session");
169
+ const markerFile = fileAccessor(markerPath);
170
+
171
+ expect(await markerFile.exists()).toBe(true);
172
+
173
+ const sessionDir = (await markerFile.text()).trim();
174
+ expect(sessionDir).toBeTruthy();
175
+ expect(sessionDir).toContain(logsDir);
176
+
177
+ // Session directory should exist
178
+ const dirStat = await stat(sessionDir);
179
+ expect(dirStat.isDirectory()).toBe(true);
180
+ });
181
+
182
+ test("tool-start creates log files in session directory", async () => {
183
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Write"]);
184
+
185
+ // Wait for async file writes to complete
186
+ await new Promise((resolve) => setTimeout(resolve, 50));
187
+
188
+ const logsDir = join(tempDir, ".legio", "logs", "test-builder");
189
+ const markerPath = join(logsDir, ".current-session");
190
+ const sessionDir = (await readFile(markerPath, "utf-8")).trim();
191
+
192
+ // Check for events.ndjson file
193
+ const eventsFile = fileAccessor(join(sessionDir, "events.ndjson"));
194
+ expect(await eventsFile.exists()).toBe(true);
195
+ });
196
+
197
+ test("tool-end uses the same session directory as tool-start", async () => {
198
+ await logCommand(["tool-start", "--agent", "test-agent", "--tool-name", "Edit"]);
199
+
200
+ const logsDir = join(tempDir, ".legio", "logs", "test-agent");
201
+ const markerPath = join(logsDir, ".current-session");
202
+ const sessionDirAfterStart = (await readFile(markerPath, "utf-8")).trim();
203
+
204
+ await logCommand(["tool-end", "--agent", "test-agent", "--tool-name", "Edit"]);
205
+
206
+ const sessionDirAfterEnd = (await readFile(markerPath, "utf-8")).trim();
207
+ expect(sessionDirAfterEnd).toBe(sessionDirAfterStart);
208
+ });
209
+
210
+ test("tool-end writes to the same session directory", async () => {
211
+ await logCommand(["tool-start", "--agent", "test-worker", "--tool-name", "Bash"]);
212
+ await logCommand(["tool-end", "--agent", "test-worker", "--tool-name", "Bash"]);
213
+
214
+ // Wait for async file writes to complete
215
+ await new Promise((resolve) => setTimeout(resolve, 50));
216
+
217
+ const logsDir = join(tempDir, ".legio", "logs", "test-worker");
218
+ const markerPath = join(logsDir, ".current-session");
219
+ const sessionDir = (await readFile(markerPath, "utf-8")).trim();
220
+
221
+ // Events file should contain both tool-start and tool-end events
222
+ const eventsFile = fileAccessor(join(sessionDir, "events.ndjson"));
223
+ const eventsContent = await eventsFile.text();
224
+
225
+ expect(eventsContent).toContain("tool.start");
226
+ expect(eventsContent).toContain("tool.end");
227
+ });
228
+
229
+ test("session-end transitions agent state to completed in sessions.db", async () => {
230
+ // Create sessions.db with a test agent
231
+ const dbPath = join(tempDir, ".legio", "sessions.db");
232
+ const session: AgentSession = {
233
+ id: "session-001",
234
+ agentName: "test-agent",
235
+ capability: "builder",
236
+ worktreePath: "/tmp/test",
237
+ branchName: "test-branch",
238
+ beadId: "bead-001",
239
+ tmuxSession: "test-tmux",
240
+ state: "working",
241
+ pid: 12345,
242
+ parentAgent: null,
243
+ depth: 0,
244
+ runId: null,
245
+ startedAt: new Date().toISOString(),
246
+ lastActivity: new Date().toISOString(),
247
+ escalationLevel: 0,
248
+ stalledSince: null,
249
+ };
250
+ const store = createSessionStore(dbPath);
251
+ store.upsert(session);
252
+ store.close();
253
+
254
+ await logCommand(["session-end", "--agent", "test-agent"]);
255
+
256
+ // Read sessions.db and verify state changed to completed
257
+ const readStore = createSessionStore(dbPath);
258
+ const updatedSession = readStore.getByName("test-agent");
259
+ readStore.close();
260
+
261
+ expect(updatedSession).toBeDefined();
262
+ expect(updatedSession?.state).toBe("completed");
263
+ });
264
+
265
+ test("session-end clears the .current-session marker", async () => {
266
+ // First create a session with tool-start
267
+ await logCommand(["tool-start", "--agent", "test-cleanup", "--tool-name", "Read"]);
268
+
269
+ const logsDir = join(tempDir, ".legio", "logs", "test-cleanup");
270
+ const markerPath = join(logsDir, ".current-session");
271
+
272
+ // Verify marker exists before session-end
273
+ let markerFile = fileAccessor(markerPath);
274
+ expect(await markerFile.exists()).toBe(true);
275
+
276
+ // Now end the session
277
+ await logCommand(["session-end", "--agent", "test-cleanup"]);
278
+
279
+ // Marker should be removed - need to create a new file accessor reference
280
+ markerFile = fileAccessor(markerPath);
281
+ expect(await markerFile.exists()).toBe(false);
282
+ });
283
+
284
+ test("session-end records metrics when agent session exists in sessions.db", async () => {
285
+ // Create sessions.db with a test agent
286
+ const sessionsDbPath = join(tempDir, ".legio", "sessions.db");
287
+ const session: AgentSession = {
288
+ id: "session-002",
289
+ agentName: "metrics-agent",
290
+ capability: "scout",
291
+ worktreePath: "/tmp/metrics",
292
+ branchName: "metrics-branch",
293
+ beadId: "bead-002",
294
+ tmuxSession: "metrics-tmux",
295
+ state: "working",
296
+ pid: 54321,
297
+ parentAgent: "parent-agent",
298
+ depth: 1,
299
+ runId: null,
300
+ startedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
301
+ lastActivity: new Date().toISOString(),
302
+ escalationLevel: 0,
303
+ stalledSince: null,
304
+ };
305
+ const sessStore = createSessionStore(sessionsDbPath);
306
+ sessStore.upsert(session);
307
+ sessStore.close();
308
+
309
+ await logCommand(["session-end", "--agent", "metrics-agent"]);
310
+
311
+ // Verify metrics.db was created and has the session record
312
+ const metricsDbPath = join(tempDir, ".legio", "metrics.db");
313
+ const metricsStore = createMetricsStore(metricsDbPath);
314
+ const metrics = metricsStore.getRecentSessions(1);
315
+ metricsStore.close();
316
+
317
+ expect(metrics).toHaveLength(1);
318
+ expect(metrics[0]?.agentName).toBe("metrics-agent");
319
+ expect(metrics[0]?.beadId).toBe("bead-002");
320
+ expect(metrics[0]?.capability).toBe("scout");
321
+ expect(metrics[0]?.parentAgent).toBe("parent-agent");
322
+ });
323
+
324
+ test("session-end does NOT transition coordinator to completed (persistent agent)", async () => {
325
+ // Create sessions.db with a coordinator agent
326
+ const dbPath = join(tempDir, ".legio", "sessions.db");
327
+ const session: AgentSession = {
328
+ id: "session-coord",
329
+ agentName: "coordinator",
330
+ capability: "coordinator",
331
+ worktreePath: tempDir,
332
+ branchName: "main",
333
+ beadId: "",
334
+ tmuxSession: "legio-coordinator",
335
+ state: "working",
336
+ pid: 11111,
337
+ parentAgent: null,
338
+ depth: 0,
339
+ runId: null,
340
+ startedAt: new Date().toISOString(),
341
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
342
+ escalationLevel: 0,
343
+ stalledSince: null,
344
+ };
345
+ const store = createSessionStore(dbPath);
346
+ store.upsert(session);
347
+ store.close();
348
+
349
+ await logCommand(["session-end", "--agent", "coordinator"]);
350
+
351
+ // Coordinator should remain 'working', not transition to 'completed'
352
+ const readStore = createSessionStore(dbPath);
353
+ const updatedSession = readStore.getByName("coordinator");
354
+ readStore.close();
355
+
356
+ expect(updatedSession).toBeDefined();
357
+ expect(updatedSession?.state).toBe("working");
358
+ // But lastActivity should be updated
359
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
360
+ new Date(session.lastActivity).getTime(),
361
+ );
362
+ });
363
+
364
+ test("session-end does NOT transition monitor to completed (persistent agent)", async () => {
365
+ const dbPath = join(tempDir, ".legio", "sessions.db");
366
+ const session: AgentSession = {
367
+ id: "session-mon",
368
+ agentName: "monitor",
369
+ capability: "monitor",
370
+ worktreePath: tempDir,
371
+ branchName: "main",
372
+ beadId: "",
373
+ tmuxSession: "legio-monitor",
374
+ state: "working",
375
+ pid: 22222,
376
+ parentAgent: null,
377
+ depth: 0,
378
+ runId: null,
379
+ startedAt: new Date().toISOString(),
380
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
381
+ escalationLevel: 0,
382
+ stalledSince: null,
383
+ };
384
+ const store = createSessionStore(dbPath);
385
+ store.upsert(session);
386
+ store.close();
387
+
388
+ await logCommand(["session-end", "--agent", "monitor"]);
389
+
390
+ const readStore = createSessionStore(dbPath);
391
+ const updatedSession = readStore.getByName("monitor");
392
+ readStore.close();
393
+
394
+ expect(updatedSession).toBeDefined();
395
+ expect(updatedSession?.state).toBe("working");
396
+ });
397
+
398
+ describe("session-end coordinator run completion", () => {
399
+ test("session-end auto-completes the active run for coordinator agent", async () => {
400
+ // Create sessions.db with coordinator and a run
401
+ const dbPath = join(tempDir, ".legio", "sessions.db");
402
+ const sessionStoreLocal = createSessionStore(dbPath);
403
+ sessionStoreLocal.upsert({
404
+ id: "session-coord-run",
405
+ agentName: "coordinator",
406
+ capability: "coordinator",
407
+ worktreePath: tempDir,
408
+ branchName: "main",
409
+ beadId: "",
410
+ tmuxSession: "legio-coordinator",
411
+ state: "working",
412
+ pid: 11111,
413
+ parentAgent: null,
414
+ depth: 0,
415
+ runId: "run-test-001",
416
+ startedAt: new Date().toISOString(),
417
+ lastActivity: new Date().toISOString(),
418
+ escalationLevel: 0,
419
+ stalledSince: null,
420
+ });
421
+ sessionStoreLocal.close();
422
+
423
+ // Create the run
424
+ const runStore = createRunStore(dbPath);
425
+ runStore.createRun({
426
+ id: "run-test-001",
427
+ startedAt: new Date().toISOString(),
428
+ coordinatorSessionId: "session-coord-run",
429
+ status: "active",
430
+ });
431
+ runStore.close();
432
+
433
+ // Write current-run.txt
434
+ const currentRunPath = join(tempDir, ".legio", "current-run.txt");
435
+ await writeFile(currentRunPath, "run-test-001");
436
+
437
+ // Verify current-run.txt exists before test
438
+ expect(
439
+ await access(currentRunPath).then(
440
+ () => true,
441
+ () => false,
442
+ ),
443
+ ).toBe(true);
444
+
445
+ // Call session-end
446
+ await logCommand(["session-end", "--agent", "coordinator"]);
447
+
448
+ // Verify: run status is "completed" in RunStore
449
+ const runStoreRead = createRunStore(dbPath);
450
+ const run = runStoreRead.getRun("run-test-001");
451
+ runStoreRead.close();
452
+
453
+ expect(run).toBeDefined();
454
+ expect(run?.status).toBe("completed");
455
+ expect(run?.completedAt).toBeTruthy();
456
+
457
+ // Verify: current-run.txt is deleted (create fresh file reference)
458
+ expect(
459
+ await access(currentRunPath).then(
460
+ () => true,
461
+ () => false,
462
+ ),
463
+ ).toBe(false);
464
+ });
465
+
466
+ test("session-end does not fail when no active run for coordinator", async () => {
467
+ // Create a coordinator session but no current-run.txt
468
+ const dbPath = join(tempDir, ".legio", "sessions.db");
469
+ const sessionStoreLocal = createSessionStore(dbPath);
470
+ sessionStoreLocal.upsert({
471
+ id: "session-coord-no-run",
472
+ agentName: "coordinator-no-run",
473
+ capability: "coordinator",
474
+ worktreePath: tempDir,
475
+ branchName: "main",
476
+ beadId: "",
477
+ tmuxSession: "legio-coordinator-no-run",
478
+ state: "working",
479
+ pid: 11112,
480
+ parentAgent: null,
481
+ depth: 0,
482
+ runId: null,
483
+ startedAt: new Date().toISOString(),
484
+ lastActivity: new Date().toISOString(),
485
+ escalationLevel: 0,
486
+ stalledSince: null,
487
+ });
488
+ sessionStoreLocal.close();
489
+
490
+ // Call session-end (should not throw)
491
+ await expect(async () => {
492
+ await logCommand(["session-end", "--agent", "coordinator-no-run"]);
493
+ }).not.toThrow();
494
+ });
495
+
496
+ test("session-end does not complete run for non-coordinator agents", async () => {
497
+ // Create a builder session, create a run, write current-run.txt
498
+ const dbPath = join(tempDir, ".legio", "sessions.db");
499
+ const sessionStoreLocal = createSessionStore(dbPath);
500
+ sessionStoreLocal.upsert({
501
+ id: "session-builder-run",
502
+ agentName: "test-builder",
503
+ capability: "builder",
504
+ worktreePath: tempDir,
505
+ branchName: "builder-branch",
506
+ beadId: "bead-builder-001",
507
+ tmuxSession: "legio-builder",
508
+ state: "working",
509
+ pid: 11113,
510
+ parentAgent: null,
511
+ depth: 2,
512
+ runId: "run-test-002",
513
+ startedAt: new Date().toISOString(),
514
+ lastActivity: new Date().toISOString(),
515
+ escalationLevel: 0,
516
+ stalledSince: null,
517
+ });
518
+ sessionStoreLocal.close();
519
+
520
+ // Create the run
521
+ const runStore = createRunStore(dbPath);
522
+ runStore.createRun({
523
+ id: "run-test-002",
524
+ startedAt: new Date().toISOString(),
525
+ coordinatorSessionId: "session-coord-run",
526
+ status: "active",
527
+ });
528
+ runStore.close();
529
+
530
+ // Write current-run.txt
531
+ await writeFile(join(tempDir, ".legio", "current-run.txt"), "run-test-002");
532
+
533
+ // Call session-end for builder
534
+ await logCommand(["session-end", "--agent", "test-builder"]);
535
+
536
+ // Verify: run status remains "active"
537
+ const runStoreRead = createRunStore(dbPath);
538
+ const run = runStoreRead.getRun("run-test-002");
539
+ runStoreRead.close();
540
+
541
+ expect(run).toBeDefined();
542
+ expect(run?.status).toBe("active");
543
+ expect(run?.completedAt).toBeNull();
544
+
545
+ // Verify: current-run.txt still exists
546
+ const currentRunFile = fileAccessor(join(tempDir, ".legio", "current-run.txt"));
547
+ expect(await currentRunFile.exists()).toBe(true);
548
+ });
549
+
550
+ test("session-end handles already-completed run gracefully", async () => {
551
+ // Create a coordinator session, create a run that is already completed
552
+ const dbPath = join(tempDir, ".legio", "sessions.db");
553
+ const sessionStoreLocal = createSessionStore(dbPath);
554
+ sessionStoreLocal.upsert({
555
+ id: "session-coord-completed",
556
+ agentName: "coordinator-completed",
557
+ capability: "coordinator",
558
+ worktreePath: tempDir,
559
+ branchName: "main",
560
+ beadId: "",
561
+ tmuxSession: "legio-coordinator-completed",
562
+ state: "working",
563
+ pid: 11114,
564
+ parentAgent: null,
565
+ depth: 0,
566
+ runId: "run-test-003",
567
+ startedAt: new Date().toISOString(),
568
+ lastActivity: new Date().toISOString(),
569
+ escalationLevel: 0,
570
+ stalledSince: null,
571
+ });
572
+ sessionStoreLocal.close();
573
+
574
+ // Create the run already completed
575
+ const runStore = createRunStore(dbPath);
576
+ runStore.createRun({
577
+ id: "run-test-003",
578
+ startedAt: new Date().toISOString(),
579
+ coordinatorSessionId: "session-coord-completed",
580
+ status: "active",
581
+ });
582
+ // Complete it immediately
583
+ runStore.completeRun("run-test-003", "completed");
584
+ runStore.close();
585
+
586
+ // Write current-run.txt
587
+ await writeFile(join(tempDir, ".legio", "current-run.txt"), "run-test-003");
588
+
589
+ // Call session-end (should not throw — completeRun is idempotent)
590
+ await expect(async () => {
591
+ await logCommand(["session-end", "--agent", "coordinator-completed"]);
592
+ }).not.toThrow();
593
+
594
+ // Verify: run is still completed
595
+ const runStoreRead = createRunStore(dbPath);
596
+ const run = runStoreRead.getRun("run-test-003");
597
+ runStoreRead.close();
598
+
599
+ expect(run).toBeDefined();
600
+ expect(run?.status).toBe("completed");
601
+ });
602
+ });
603
+
604
+ test("session-end writes pending-nudge marker for coordinator when lead completes", async () => {
605
+ // Create sessions.db with a lead agent
606
+ const dbPath = join(tempDir, ".legio", "sessions.db");
607
+ const session: AgentSession = {
608
+ id: "session-lead",
609
+ agentName: "lead-alpha",
610
+ capability: "lead",
611
+ worktreePath: tempDir,
612
+ branchName: "lead-alpha-branch",
613
+ beadId: "bead-lead-001",
614
+ tmuxSession: "legio-lead-alpha",
615
+ state: "working",
616
+ pid: 33333,
617
+ parentAgent: null,
618
+ depth: 0,
619
+ runId: null,
620
+ startedAt: new Date().toISOString(),
621
+ lastActivity: new Date().toISOString(),
622
+ escalationLevel: 0,
623
+ stalledSince: null,
624
+ };
625
+ const store = createSessionStore(dbPath);
626
+ store.upsert(session);
627
+ store.close();
628
+
629
+ await logCommand(["session-end", "--agent", "lead-alpha"]);
630
+
631
+ // Verify the pending-nudge marker was written for the coordinator
632
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "coordinator.json");
633
+ const markerFile = fileAccessor(markerPath);
634
+ expect(await markerFile.exists()).toBe(true);
635
+
636
+ const marker = JSON.parse(await markerFile.text());
637
+ expect(marker.from).toBe("lead-alpha");
638
+ expect(marker.reason).toBe("lead_completed");
639
+ expect(marker.subject).toContain("lead-alpha");
640
+ expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
641
+ expect(marker.createdAt).toBeDefined();
642
+ });
643
+
644
+ test("session-end does NOT write pending-nudge marker for non-lead agents", async () => {
645
+ // Create sessions.db with a builder agent (not a lead)
646
+ const dbPath = join(tempDir, ".legio", "sessions.db");
647
+ const session: AgentSession = {
648
+ id: "session-builder",
649
+ agentName: "builder-beta",
650
+ capability: "builder",
651
+ worktreePath: tempDir,
652
+ branchName: "builder-beta-branch",
653
+ beadId: "bead-builder-001",
654
+ tmuxSession: "legio-builder-beta",
655
+ state: "working",
656
+ pid: 44444,
657
+ parentAgent: null,
658
+ depth: 0,
659
+ runId: null,
660
+ startedAt: new Date().toISOString(),
661
+ lastActivity: new Date().toISOString(),
662
+ escalationLevel: 0,
663
+ stalledSince: null,
664
+ };
665
+ const store = createSessionStore(dbPath);
666
+ store.upsert(session);
667
+ store.close();
668
+
669
+ await logCommand(["session-end", "--agent", "builder-beta"]);
670
+
671
+ // Verify no pending-nudge marker was written
672
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "coordinator.json");
673
+ const markerFile = fileAccessor(markerPath);
674
+ expect(await markerFile.exists()).toBe(false);
675
+ });
676
+
677
+ test("session-end does not crash when sessions.db does not exist", async () => {
678
+ // No sessions.db file exists
679
+ // session-end should complete without throwing
680
+ await expect(
681
+ logCommand(["session-end", "--agent", "nonexistent-agent"]),
682
+ ).resolves.toBeUndefined();
683
+ });
684
+
685
+ test("tool-start updates lastActivity timestamp in sessions.db", async () => {
686
+ // Create sessions.db with a test agent
687
+ const dbPath = join(tempDir, ".legio", "sessions.db");
688
+ const oldTimestamp = new Date(Date.now() - 120_000).toISOString(); // 2 minutes ago
689
+ const session: AgentSession = {
690
+ id: "session-003",
691
+ agentName: "activity-agent",
692
+ capability: "builder",
693
+ worktreePath: "/tmp/activity",
694
+ branchName: "activity-branch",
695
+ beadId: "bead-003",
696
+ tmuxSession: "activity-tmux",
697
+ state: "working",
698
+ pid: 99999,
699
+ parentAgent: null,
700
+ depth: 0,
701
+ runId: null,
702
+ startedAt: oldTimestamp,
703
+ lastActivity: oldTimestamp,
704
+ escalationLevel: 0,
705
+ stalledSince: null,
706
+ };
707
+ const store = createSessionStore(dbPath);
708
+ store.upsert(session);
709
+ store.close();
710
+
711
+ await logCommand(["tool-start", "--agent", "activity-agent", "--tool-name", "Glob"]);
712
+
713
+ // Read sessions.db and verify lastActivity was updated
714
+ const readStore = createSessionStore(dbPath);
715
+ const updatedSession = readStore.getByName("activity-agent");
716
+ readStore.close();
717
+
718
+ expect(updatedSession).toBeDefined();
719
+ expect(updatedSession?.lastActivity).not.toBe(oldTimestamp);
720
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
721
+ new Date(oldTimestamp).getTime(),
722
+ );
723
+ });
724
+
725
+ test("tool-start transitions state from booting to working", async () => {
726
+ // Create sessions.db with agent in 'booting' state
727
+ const dbPath = join(tempDir, ".legio", "sessions.db");
728
+ const session: AgentSession = {
729
+ id: "session-004",
730
+ agentName: "booting-agent",
731
+ capability: "builder",
732
+ worktreePath: "/tmp/booting",
733
+ branchName: "booting-branch",
734
+ beadId: "bead-004",
735
+ tmuxSession: "booting-tmux",
736
+ state: "booting",
737
+ pid: 11111,
738
+ parentAgent: null,
739
+ depth: 0,
740
+ runId: null,
741
+ startedAt: new Date().toISOString(),
742
+ lastActivity: new Date().toISOString(),
743
+ escalationLevel: 0,
744
+ stalledSince: null,
745
+ };
746
+ const store = createSessionStore(dbPath);
747
+ store.upsert(session);
748
+ store.close();
749
+
750
+ await logCommand(["tool-start", "--agent", "booting-agent", "--tool-name", "Read"]);
751
+
752
+ // Read sessions.db and verify state changed to working
753
+ const readStore = createSessionStore(dbPath);
754
+ const updatedSession = readStore.getByName("booting-agent");
755
+ readStore.close();
756
+
757
+ expect(updatedSession).toBeDefined();
758
+ expect(updatedSession?.state).toBe("working");
759
+ });
760
+
761
+ test("tool-start defaults to unknown when --tool-name not provided", async () => {
762
+ // Should not throw when --tool-name is missing
763
+ await expect(
764
+ logCommand(["tool-start", "--agent", "default-tool-agent"]),
765
+ ).resolves.toBeUndefined();
766
+
767
+ // Verify log was created
768
+ const logsDir = join(tempDir, ".legio", "logs", "default-tool-agent");
769
+ const markerPath = join(logsDir, ".current-session");
770
+ const markerFile = fileAccessor(markerPath);
771
+
772
+ expect(await markerFile.exists()).toBe(true);
773
+
774
+ // Wait for async file writes to complete (logger uses fire-and-forget appendFile)
775
+ await new Promise((resolve) => setTimeout(resolve, 50));
776
+
777
+ const sessionDir = (await markerFile.text()).trim();
778
+ const eventsFile = fileAccessor(join(sessionDir, "events.ndjson"));
779
+ const eventsContent = await eventsFile.text();
780
+
781
+ // Should contain "unknown" as the tool name
782
+ expect(eventsContent).toContain("unknown");
783
+ });
784
+
785
+ test("tool-end defaults to unknown when --tool-name not provided", async () => {
786
+ await logCommand(["tool-start", "--agent", "default-end-agent"]);
787
+
788
+ // tool-end without --tool-name should not throw
789
+ await expect(logCommand(["tool-end", "--agent", "default-end-agent"])).resolves.toBeUndefined();
790
+
791
+ // Wait for async file writes to complete
792
+ await new Promise((resolve) => setTimeout(resolve, 50));
793
+
794
+ const logsDir = join(tempDir, ".legio", "logs", "default-end-agent");
795
+ const markerPath = join(logsDir, ".current-session");
796
+ const sessionDir = (await readFile(markerPath, "utf-8")).trim();
797
+ const eventsFile = fileAccessor(join(sessionDir, "events.ndjson"));
798
+ const eventsContent = await eventsFile.text();
799
+
800
+ expect(eventsContent).toContain("unknown");
801
+ });
802
+
803
+ test("--help includes --stdin option in output", async () => {
804
+ await logCommand(["--help"]);
805
+ const out = output();
806
+
807
+ expect(out).toContain("--stdin");
808
+ });
809
+
810
+ test("session-end does not crash when mulch learn/record fails", async () => {
811
+ // Create sessions.db with a builder agent (non-persistent)
812
+ const dbPath = join(tempDir, ".legio", "sessions.db");
813
+ const session: AgentSession = {
814
+ id: "session-mulch-fail",
815
+ agentName: "mulch-fail-agent",
816
+ capability: "builder",
817
+ worktreePath: tempDir,
818
+ branchName: "mulch-fail-branch",
819
+ beadId: "bead-mulch-001",
820
+ tmuxSession: "legio-mulch-fail",
821
+ state: "working",
822
+ pid: 55555,
823
+ parentAgent: "parent-agent",
824
+ depth: 1,
825
+ runId: null,
826
+ startedAt: new Date().toISOString(),
827
+ lastActivity: new Date().toISOString(),
828
+ escalationLevel: 0,
829
+ stalledSince: null,
830
+ };
831
+ const store = createSessionStore(dbPath);
832
+ store.upsert(session);
833
+ store.close();
834
+
835
+ // session-end should complete without throwing even if mulch learn/record fails
836
+ await expect(
837
+ logCommand(["session-end", "--agent", "mulch-fail-agent"]),
838
+ ).resolves.toBeUndefined();
839
+
840
+ // Verify state transitioned to completed
841
+ const readStore = createSessionStore(dbPath);
842
+ const updatedSession = readStore.getByName("mulch-fail-agent");
843
+ readStore.close();
844
+
845
+ expect(updatedSession).toBeDefined();
846
+ expect(updatedSession?.state).toBe("completed");
847
+ });
848
+
849
+ test("session-end skips mulch auto-record for coordinator (persistent agent)", async () => {
850
+ // Create sessions.db with a coordinator agent
851
+ const dbPath = join(tempDir, ".legio", "sessions.db");
852
+ const session: AgentSession = {
853
+ id: "session-coord-mulch",
854
+ agentName: "coordinator-mulch",
855
+ capability: "coordinator",
856
+ worktreePath: tempDir,
857
+ branchName: "main",
858
+ beadId: "",
859
+ tmuxSession: "legio-coordinator-mulch",
860
+ state: "working",
861
+ pid: 66666,
862
+ parentAgent: null,
863
+ depth: 0,
864
+ runId: null,
865
+ startedAt: new Date().toISOString(),
866
+ lastActivity: new Date().toISOString(),
867
+ escalationLevel: 0,
868
+ stalledSince: null,
869
+ };
870
+ const store = createSessionStore(dbPath);
871
+ store.upsert(session);
872
+ store.close();
873
+
874
+ await logCommand(["session-end", "--agent", "coordinator-mulch"]);
875
+
876
+ // Verify no mail.db was created (mulch auto-record was skipped)
877
+ const mailDbPath = join(tempDir, ".legio", "mail.db");
878
+ const mailDbFile = fileAccessor(mailDbPath);
879
+ expect(await mailDbFile.exists()).toBe(false);
880
+
881
+ // Coordinator should remain working (persistent agent)
882
+ const readStore = createSessionStore(dbPath);
883
+ const updatedSession = readStore.getByName("coordinator-mulch");
884
+ readStore.close();
885
+
886
+ expect(updatedSession).toBeDefined();
887
+ expect(updatedSession?.state).toBe("working");
888
+ });
889
+
890
+ test("autoRecordExpertise calls record for each suggested domain", async () => {
891
+ const learnResult: MulchLearnResult = {
892
+ success: true,
893
+ command: "mulch learn",
894
+ changedFiles: ["src/foo.ts", "src/bar.ts"],
895
+ suggestedDomains: ["typescript", "cli"],
896
+ unmatchedFiles: [],
897
+ };
898
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
899
+ const mailDbPath = join(tempDir, ".legio", "auto-record-mail.db");
900
+
901
+ const result = await autoRecordExpertise({
902
+ mulchClient: client,
903
+ agentName: "test-builder",
904
+ capability: "builder",
905
+ beadId: "bead-123",
906
+ mailDbPath,
907
+ parentAgent: "parent-lead",
908
+ projectRoot: tempDir,
909
+ sessionStartedAt: new Date().toISOString(),
910
+ });
911
+
912
+ expect(result).toEqual(["typescript", "cli"]);
913
+ expect(recordCalls).toHaveLength(2);
914
+ expect(recordCalls[0]?.domain).toBe("typescript");
915
+ expect(recordCalls[0]?.options).toMatchObject({
916
+ type: "reference",
917
+ tags: ["auto-session-end", "builder"],
918
+ evidenceBead: "bead-123",
919
+ });
920
+ expect(recordCalls[1]?.domain).toBe("cli");
921
+ });
922
+
923
+ test("autoRecordExpertise sends mail with auto-recorded subject", async () => {
924
+ const learnResult: MulchLearnResult = {
925
+ success: true,
926
+ command: "mulch learn",
927
+ changedFiles: ["src/foo.ts"],
928
+ suggestedDomains: ["typescript"],
929
+ unmatchedFiles: [],
930
+ };
931
+ const { client } = createFakeMulchClient(learnResult);
932
+ const mailDbPath = join(tempDir, ".legio", "auto-record-mail2.db");
933
+
934
+ await autoRecordExpertise({
935
+ mulchClient: client,
936
+ agentName: "test-builder",
937
+ capability: "builder",
938
+ beadId: "bead-456",
939
+ mailDbPath,
940
+ parentAgent: "parent-lead",
941
+ projectRoot: tempDir,
942
+ sessionStartedAt: new Date().toISOString(),
943
+ });
944
+
945
+ const mailStore = createMailStore(mailDbPath);
946
+ const mailClient = createMailClient(mailStore);
947
+ const messages = mailClient.list({ to: "parent-lead" });
948
+ mailClient.close();
949
+
950
+ expect(messages).toHaveLength(1);
951
+ expect(messages[0]?.subject).toBe("mulch: auto-recorded insights in typescript");
952
+ expect(messages[0]?.body).toContain("Auto-recorded expertise in: typescript");
953
+ });
954
+
955
+ test("autoRecordExpertise continues when individual record calls fail", async () => {
956
+ const learnResult: MulchLearnResult = {
957
+ success: true,
958
+ command: "mulch learn",
959
+ changedFiles: ["src/foo.ts"],
960
+ suggestedDomains: ["typescript", "cli"],
961
+ unmatchedFiles: [],
962
+ };
963
+ const { client } = createFakeMulchClient(learnResult, { recordShouldFail: true });
964
+ const mailDbPath = join(tempDir, ".legio", "auto-record-fail.db");
965
+
966
+ const result = await autoRecordExpertise({
967
+ mulchClient: client,
968
+ agentName: "test-builder",
969
+ capability: "builder",
970
+ beadId: null,
971
+ mailDbPath,
972
+ parentAgent: null,
973
+ projectRoot: tempDir,
974
+ sessionStartedAt: new Date().toISOString(),
975
+ });
976
+
977
+ // All records failed, so no domains recorded and no mail sent
978
+ expect(result).toEqual([]);
979
+ const mailFile = fileAccessor(mailDbPath);
980
+ expect(await mailFile.exists()).toBe(false);
981
+ });
982
+
983
+ test("autoRecordExpertise returns empty when no domains suggested", async () => {
984
+ const learnResult: MulchLearnResult = {
985
+ success: true,
986
+ command: "mulch learn",
987
+ changedFiles: ["src/foo.ts"],
988
+ suggestedDomains: [],
989
+ unmatchedFiles: [],
990
+ };
991
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
992
+ const mailDbPath = join(tempDir, ".legio", "auto-record-empty.db");
993
+
994
+ const result = await autoRecordExpertise({
995
+ mulchClient: client,
996
+ agentName: "test-builder",
997
+ capability: "builder",
998
+ beadId: null,
999
+ mailDbPath,
1000
+ parentAgent: null,
1001
+ projectRoot: tempDir,
1002
+ sessionStartedAt: new Date().toISOString(),
1003
+ });
1004
+
1005
+ expect(result).toEqual([]);
1006
+ expect(recordCalls).toHaveLength(0);
1007
+ });
1008
+
1009
+ test("autoRecordExpertise records pattern insights when EventStore has tool data", async () => {
1010
+ const learnResult: MulchLearnResult = {
1011
+ success: true,
1012
+ command: "mulch learn",
1013
+ changedFiles: ["src/mail/store.ts"],
1014
+ suggestedDomains: ["messaging"],
1015
+ unmatchedFiles: [],
1016
+ };
1017
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
1018
+ const mailDbPath = join(tempDir, ".legio", "insight-analysis-mail.db");
1019
+
1020
+ // Create EventStore with test data
1021
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1022
+ const eventStore = createEventStore(eventsDbPath);
1023
+
1024
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
1025
+
1026
+ // Insert tool events: 15 tool calls total (10+ triggers workflow insight)
1027
+ // Read-heavy: 12 Read, 3 Edit → should classify as read-heavy
1028
+ for (let i = 0; i < 12; i++) {
1029
+ eventStore.insert({
1030
+ runId: null,
1031
+ agentName: "insight-agent",
1032
+ sessionId: "sess-insight",
1033
+ eventType: "tool_start",
1034
+ toolName: "Read",
1035
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1036
+ toolDurationMs: null,
1037
+ level: "info",
1038
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1039
+ });
1040
+ }
1041
+
1042
+ // Add 4 edits to same file → hot file
1043
+ for (let i = 0; i < 4; i++) {
1044
+ eventStore.insert({
1045
+ runId: null,
1046
+ agentName: "insight-agent",
1047
+ sessionId: "sess-insight",
1048
+ eventType: "tool_start",
1049
+ toolName: "Edit",
1050
+ toolArgs: JSON.stringify({ file_path: "src/mail/store.ts" }),
1051
+ toolDurationMs: null,
1052
+ level: "info",
1053
+ data: JSON.stringify({ summary: "edit: src/mail/store.ts" }),
1054
+ });
1055
+ }
1056
+
1057
+ // Add 1 error event → error pattern
1058
+ eventStore.insert({
1059
+ runId: null,
1060
+ agentName: "insight-agent",
1061
+ sessionId: "sess-insight",
1062
+ eventType: "tool_start",
1063
+ toolName: "Bash",
1064
+ toolArgs: JSON.stringify({ command: "npm test" }),
1065
+ toolDurationMs: null,
1066
+ level: "error",
1067
+ data: "Test failed",
1068
+ });
1069
+
1070
+ eventStore.close();
1071
+
1072
+ // Run autoRecordExpertise
1073
+ const result = await autoRecordExpertise({
1074
+ mulchClient: client,
1075
+ agentName: "insight-agent",
1076
+ capability: "builder",
1077
+ beadId: "bead-insight",
1078
+ mailDbPath,
1079
+ parentAgent: "parent-agent",
1080
+ projectRoot: tempDir,
1081
+ sessionStartedAt,
1082
+ });
1083
+
1084
+ // Verify reference + insights were recorded
1085
+ expect(recordCalls.length).toBeGreaterThanOrEqual(2); // At least reference + 1 insight
1086
+
1087
+ // Verify reference entry
1088
+ const referenceCall = recordCalls.find((c) => c.options.type === "reference");
1089
+ expect(referenceCall).toBeDefined();
1090
+ expect(referenceCall?.domain).toBe("messaging");
1091
+
1092
+ // Verify pattern insights
1093
+ const patternCalls = recordCalls.filter((c) => c.options.type === "pattern");
1094
+ expect(patternCalls.length).toBeGreaterThanOrEqual(2);
1095
+
1096
+ // Verify workflow insight
1097
+ const workflowInsight = patternCalls.find((c) => {
1098
+ const desc = c.options.description;
1099
+ return typeof desc === "string" && desc.includes("read-heavy workflow");
1100
+ });
1101
+ expect(workflowInsight).toBeDefined();
1102
+
1103
+ // Verify hot file insight
1104
+ const hotFileInsight = patternCalls.find((c) => {
1105
+ const desc = c.options.description;
1106
+ return (
1107
+ typeof desc === "string" && desc.includes("src/mail/store.ts") && desc.includes("4 edits")
1108
+ );
1109
+ });
1110
+ expect(hotFileInsight).toBeDefined();
1111
+ expect(hotFileInsight?.domain).toBe("messaging"); // Inferred from src/mail/
1112
+
1113
+ // Verify failure insight
1114
+ const failureCall = recordCalls.find((c) => c.options.type === "failure");
1115
+ expect(failureCall).toBeDefined();
1116
+
1117
+ // Verify recorded domains includes unique domains from insights
1118
+ expect(result).toContain("messaging");
1119
+ });
1120
+
1121
+ test("autoRecordExpertise includes insight summary in notification mail", async () => {
1122
+ const learnResult: MulchLearnResult = {
1123
+ success: true,
1124
+ command: "mulch learn",
1125
+ changedFiles: ["src/config.ts"],
1126
+ suggestedDomains: ["typescript"],
1127
+ unmatchedFiles: [],
1128
+ };
1129
+ const { client } = createFakeMulchClient(learnResult);
1130
+ const mailDbPath = join(tempDir, ".legio", "insight-mail-summary.db");
1131
+
1132
+ // Create EventStore with 10+ tool calls to trigger workflow insight
1133
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1134
+ const eventStore = createEventStore(eventsDbPath);
1135
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
1136
+
1137
+ for (let i = 0; i < 10; i++) {
1138
+ eventStore.insert({
1139
+ runId: null,
1140
+ agentName: "mail-insight-agent",
1141
+ sessionId: "sess-mail",
1142
+ eventType: "tool_start",
1143
+ toolName: "Read",
1144
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1145
+ toolDurationMs: null,
1146
+ level: "info",
1147
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1148
+ });
1149
+ }
1150
+
1151
+ eventStore.close();
1152
+
1153
+ await autoRecordExpertise({
1154
+ mulchClient: client,
1155
+ agentName: "mail-insight-agent",
1156
+ capability: "scout",
1157
+ beadId: "bead-mail",
1158
+ mailDbPath,
1159
+ parentAgent: "parent-agent",
1160
+ projectRoot: tempDir,
1161
+ sessionStartedAt,
1162
+ });
1163
+
1164
+ // Verify mail was sent with insight summary
1165
+ const mailStore = createMailStore(mailDbPath);
1166
+ const mailClient = createMailClient(mailStore);
1167
+ const messages = mailClient.list({ to: "parent-agent" });
1168
+ mailClient.close();
1169
+
1170
+ expect(messages).toHaveLength(1);
1171
+ const mail = messages[0];
1172
+ expect(mail?.body).toContain("Auto-insights:");
1173
+ expect(mail?.body).toContain("10 tool calls");
1174
+ expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
1175
+ });
1176
+
1177
+ test("tool-start creates agent-busy file", async () => {
1178
+ await logCommand(["tool-start", "--agent", "busy-agent", "--tool-name", "Read"]);
1179
+
1180
+ const busyFilePath = join(tempDir, ".legio", "agent-busy", "busy-agent");
1181
+ const busyFile = fileAccessor(busyFilePath);
1182
+ expect(await busyFile.exists()).toBe(true);
1183
+ });
1184
+
1185
+ test("tool-end removes agent-busy file", async () => {
1186
+ await logCommand(["tool-start", "--agent", "busy-remove-agent", "--tool-name", "Edit"]);
1187
+
1188
+ const busyFilePath = join(tempDir, ".legio", "agent-busy", "busy-remove-agent");
1189
+ expect(await fileAccessor(busyFilePath).exists()).toBe(true);
1190
+
1191
+ await logCommand(["tool-end", "--agent", "busy-remove-agent", "--tool-name", "Edit"]);
1192
+ expect(await fileAccessor(busyFilePath).exists()).toBe(false);
1193
+ });
1194
+
1195
+ test("tool-end does not crash when agent-busy file does not exist", async () => {
1196
+ // No tool-start first, so no busy file exists
1197
+ await expect(
1198
+ logCommand(["tool-end", "--agent", "no-busy-file-agent", "--tool-name", "Read"]),
1199
+ ).resolves.toBeUndefined();
1200
+ });
1201
+
1202
+ test("session-end for lead does not fail when direct coordinator nudge fails (no tmux)", async () => {
1203
+ // Create sessions.db with a lead agent
1204
+ const dbPath = join(tempDir, ".legio", "sessions.db");
1205
+ const session: AgentSession = {
1206
+ id: "session-lead-direct-nudge",
1207
+ agentName: "lead-direct-nudge-test",
1208
+ capability: "lead",
1209
+ worktreePath: tempDir,
1210
+ branchName: "lead-direct-nudge-branch",
1211
+ beadId: "bead-lead-dn",
1212
+ tmuxSession: "legio-lead-direct-nudge-test",
1213
+ state: "working",
1214
+ pid: 55557,
1215
+ parentAgent: null,
1216
+ depth: 0,
1217
+ runId: null,
1218
+ startedAt: new Date().toISOString(),
1219
+ lastActivity: new Date().toISOString(),
1220
+ escalationLevel: 0,
1221
+ stalledSince: null,
1222
+ };
1223
+ const store = createSessionStore(dbPath);
1224
+ store.upsert(session);
1225
+ store.close();
1226
+
1227
+ // Should not throw — nudgeAgent will fail silently (no coordinator tmux session in test env)
1228
+ await expect(
1229
+ logCommand(["session-end", "--agent", "lead-direct-nudge-test"]),
1230
+ ).resolves.toBeUndefined();
1231
+
1232
+ // Agent should be completed
1233
+ const readStore = createSessionStore(dbPath);
1234
+ const updatedSession = readStore.getByName("lead-direct-nudge-test");
1235
+ readStore.close();
1236
+ expect(updatedSession?.state).toBe("completed");
1237
+
1238
+ // Pending-nudge marker should still be written (backward compat)
1239
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "coordinator.json");
1240
+ expect(await fileAccessor(markerPath).exists()).toBe(true);
1241
+ });
1242
+ });
1243
+
1244
+ /**
1245
+ * Tests for `legio log` with --stdin flag.
1246
+ *
1247
+ * Uses child_process.spawn to invoke the log command as a subprocess with piped stdin,
1248
+ * because stdin.stream() cannot be injected in-process.
1249
+ * Real filesystem + real SQLite for EventStore verification.
1250
+ */
1251
+ describe("logCommand --stdin integration", () => {
1252
+ let tempDir: string;
1253
+
1254
+ beforeEach(async () => {
1255
+ tempDir = await mkdtemp(join(tmpdir(), "log-stdin-test-"));
1256
+ const legioDir = join(tempDir, ".legio");
1257
+ await mkdir(legioDir, { recursive: true });
1258
+ await writeFile(
1259
+ join(legioDir, "config.yaml"),
1260
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
1261
+ );
1262
+ });
1263
+
1264
+ afterEach(async () => {
1265
+ for (let i = 0; i < 3; i++) {
1266
+ try {
1267
+ await rm(tempDir, { recursive: true, force: true });
1268
+ break;
1269
+ } catch {
1270
+ if (i < 2) await new Promise((r) => setTimeout(r, 50));
1271
+ }
1272
+ }
1273
+ });
1274
+
1275
+ /**
1276
+ * Helper: run `legio log` as a subprocess with stdin piped.
1277
+ * Uses node to run the CLI entry point directly.
1278
+ */
1279
+ async function runLogWithStdin(
1280
+ event: string,
1281
+ agentName: string,
1282
+ stdinJson: Record<string, unknown>,
1283
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1284
+ // Inline script that calls logCommand with --stdin and reads from stdin
1285
+ const scriptPath = join(tempDir, "_run-log.mts");
1286
+ const scriptContent = `
1287
+ import { logCommand } from "${join(import.meta.dirname, "log.ts").replace(/\\/g, "/")}";
1288
+ const args = process.argv.slice(2);
1289
+ try {
1290
+ await logCommand(args);
1291
+ } catch (e) {
1292
+ console.error(e instanceof Error ? e.message : String(e));
1293
+ process.exit(1);
1294
+ }
1295
+ `;
1296
+ await writeFile(scriptPath, scriptContent);
1297
+
1298
+ const proc = spawn("tsx", [scriptPath, event, "--agent", agentName, "--stdin"], {
1299
+ cwd: tempDir,
1300
+ stdio: ["pipe", "pipe", "pipe"],
1301
+ });
1302
+
1303
+ const stdoutChunks: Buffer[] = [];
1304
+ const stderrChunks: Buffer[] = [];
1305
+ proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
1306
+ proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
1307
+
1308
+ // Write the JSON payload to stdin and close
1309
+ proc.stdin?.write(JSON.stringify(stdinJson));
1310
+ proc.stdin?.end();
1311
+
1312
+ const exitCode = await new Promise<number>((resolve) => {
1313
+ proc.on("close", (code: number | null) => resolve(code ?? 1));
1314
+ });
1315
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
1316
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
1317
+
1318
+ return { exitCode, stdout, stderr };
1319
+ }
1320
+
1321
+ test("tool-start with --stdin writes to EventStore", async () => {
1322
+ const payload = {
1323
+ tool_name: "Read",
1324
+ tool_input: { file_path: "/src/index.ts" },
1325
+ session_id: "sess-test-001",
1326
+ };
1327
+
1328
+ const result = await runLogWithStdin("tool-start", "stdin-builder", payload);
1329
+ expect(result.exitCode).toBe(0);
1330
+
1331
+ // Verify EventStore has the event
1332
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1333
+ const eventStore = createEventStore(eventsDbPath);
1334
+ const events = eventStore.getByAgent("stdin-builder");
1335
+ eventStore.close();
1336
+
1337
+ expect(events).toHaveLength(1);
1338
+ const event = events[0] as StoredEvent;
1339
+ expect(event.eventType).toBe("tool_start");
1340
+ expect(event.toolName).toBe("Read");
1341
+ expect(event.sessionId).toBe("sess-test-001");
1342
+ expect(event.agentName).toBe("stdin-builder");
1343
+
1344
+ // Verify filtered tool args were stored
1345
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1346
+ expect(toolArgs.file_path).toBe("/src/index.ts");
1347
+
1348
+ // Verify summary in data
1349
+ const data = JSON.parse(event.data ?? "{}");
1350
+ expect(data.summary).toBe("read: /src/index.ts");
1351
+ });
1352
+
1353
+ test("tool-end with --stdin writes to EventStore and correlates with tool-start", async () => {
1354
+ // First create a tool-start event
1355
+ const startPayload = {
1356
+ tool_name: "Bash",
1357
+ tool_input: { command: "npm test" },
1358
+ session_id: "sess-test-002",
1359
+ };
1360
+ const startResult = await runLogWithStdin("tool-start", "correlate-agent", startPayload);
1361
+ expect(startResult.exitCode).toBe(0);
1362
+
1363
+ // Small delay to ensure measurable duration
1364
+ await new Promise((resolve) => setTimeout(resolve, 10));
1365
+
1366
+ // Now send tool-end
1367
+ const endPayload = {
1368
+ tool_name: "Bash",
1369
+ tool_input: { command: "npm test" },
1370
+ session_id: "sess-test-002",
1371
+ };
1372
+ const endResult = await runLogWithStdin("tool-end", "correlate-agent", endPayload);
1373
+ expect(endResult.exitCode).toBe(0);
1374
+
1375
+ // Verify EventStore has both events
1376
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1377
+ const eventStore = createEventStore(eventsDbPath);
1378
+ const events = eventStore.getByAgent("correlate-agent");
1379
+ eventStore.close();
1380
+
1381
+ expect(events).toHaveLength(2);
1382
+
1383
+ const startEvent = events.find((e) => e.eventType === "tool_start");
1384
+ const endEvent = events.find((e) => e.eventType === "tool_end");
1385
+ expect(startEvent).toBeDefined();
1386
+ expect(endEvent).toBeDefined();
1387
+
1388
+ // The start event should have tool_duration_ms set by correlateToolEnd()
1389
+ // (value may be affected by SQLite timestamp vs Date.now() timezone behavior,
1390
+ // so we only assert it was populated — not the exact value)
1391
+ expect(startEvent?.toolDurationMs).not.toBeNull();
1392
+ });
1393
+
1394
+ test("tool-start with --stdin filters large tool_input", async () => {
1395
+ const payload = {
1396
+ tool_name: "Write",
1397
+ tool_input: {
1398
+ file_path: "/src/new-file.ts",
1399
+ content: "x".repeat(50_000), // 50KB of content — should be dropped
1400
+ },
1401
+ session_id: "sess-test-003",
1402
+ };
1403
+
1404
+ const result = await runLogWithStdin("tool-start", "filter-agent", payload);
1405
+ expect(result.exitCode).toBe(0);
1406
+
1407
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1408
+ const eventStore = createEventStore(eventsDbPath);
1409
+ const events = eventStore.getByAgent("filter-agent");
1410
+ eventStore.close();
1411
+
1412
+ expect(events).toHaveLength(1);
1413
+ const event = events[0] as StoredEvent;
1414
+
1415
+ // The Write filter keeps file_path but drops content
1416
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1417
+ expect(toolArgs.file_path).toBe("/src/new-file.ts");
1418
+ expect(toolArgs).not.toHaveProperty("content");
1419
+
1420
+ // Verify summary
1421
+ const data = JSON.parse(event.data ?? "{}");
1422
+ expect(data.summary).toBe("write: /src/new-file.ts");
1423
+ });
1424
+
1425
+ test("session-end with --stdin writes to EventStore with transcript_path", async () => {
1426
+ const payload = {
1427
+ session_id: "sess-test-004",
1428
+ transcript_path: "/tmp/transcript.jsonl",
1429
+ };
1430
+
1431
+ const result = await runLogWithStdin("session-end", "session-end-agent", payload);
1432
+ expect(result.exitCode).toBe(0);
1433
+
1434
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1435
+ const eventStore = createEventStore(eventsDbPath);
1436
+ const events = eventStore.getByAgent("session-end-agent");
1437
+ eventStore.close();
1438
+
1439
+ expect(events).toHaveLength(1);
1440
+ const event = events[0] as StoredEvent;
1441
+ expect(event.eventType).toBe("session_end");
1442
+ expect(event.sessionId).toBe("sess-test-004");
1443
+
1444
+ // Verify transcript path stored in data
1445
+ const data = JSON.parse(event.data ?? "{}");
1446
+ expect(data.transcriptPath).toBe("/tmp/transcript.jsonl");
1447
+ });
1448
+
1449
+ test("tool-start with --stdin still writes to legacy log files", async () => {
1450
+ const payload = {
1451
+ tool_name: "Grep",
1452
+ tool_input: { pattern: "TODO", path: "/src" },
1453
+ session_id: "sess-test-005",
1454
+ };
1455
+
1456
+ const result = await runLogWithStdin("tool-start", "legacy-compat-agent", payload);
1457
+ expect(result.exitCode).toBe(0);
1458
+
1459
+ // Wait for async file writes to complete
1460
+ await new Promise((resolve) => setTimeout(resolve, 50));
1461
+
1462
+ // Verify legacy log files exist
1463
+ const logsDir = join(tempDir, ".legio", "logs", "legacy-compat-agent");
1464
+ const markerPath = join(logsDir, ".current-session");
1465
+ const markerFile = fileAccessor(markerPath);
1466
+ expect(await markerFile.exists()).toBe(true);
1467
+
1468
+ const sessionDir = (await markerFile.text()).trim();
1469
+ const eventsFile = fileAccessor(join(sessionDir, "events.ndjson"));
1470
+ expect(await eventsFile.exists()).toBe(true);
1471
+
1472
+ const eventsContent = await eventsFile.text();
1473
+ expect(eventsContent).toContain("tool.start");
1474
+ expect(eventsContent).toContain("Grep");
1475
+ });
1476
+
1477
+ test("tool-start with --stdin handles empty stdin gracefully", async () => {
1478
+ // Send empty JSON object — should still work (falls back to "unknown" tool name)
1479
+ const scriptPath = join(tempDir, "_run-log-empty.mts");
1480
+ const scriptContent = `
1481
+ import { logCommand } from "${join(import.meta.dirname, "log.ts").replace(/\\/g, "/")}";
1482
+
1483
+ try {
1484
+ await logCommand(["tool-start", "--agent", "empty-stdin-agent", "--stdin"]);
1485
+ } catch (e) {
1486
+ console.error(e instanceof Error ? e.message : String(e));
1487
+ process.exit(1);
1488
+ }
1489
+ `;
1490
+ await writeFile(scriptPath, scriptContent);
1491
+
1492
+ const proc2 = spawn("tsx", [scriptPath], {
1493
+ cwd: tempDir,
1494
+ stdio: ["pipe", "pipe", "pipe"],
1495
+ });
1496
+
1497
+ // Write empty string and close immediately
1498
+ proc2.stdin?.end();
1499
+
1500
+ const exitCode = await new Promise<number>((resolve) => {
1501
+ proc2.on("close", (code: number | null) => resolve(code ?? 1));
1502
+ });
1503
+ expect(exitCode).toBe(0);
1504
+ });
1505
+
1506
+ test("tool-start with --stdin and unknown tool name uses fallback filter", async () => {
1507
+ const payload = {
1508
+ tool_name: "SomeCustomTool",
1509
+ tool_input: { custom_key: "custom_value" },
1510
+ session_id: "sess-test-006",
1511
+ };
1512
+
1513
+ const result = await runLogWithStdin("tool-start", "custom-tool-agent", payload);
1514
+ expect(result.exitCode).toBe(0);
1515
+
1516
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1517
+ const eventStore = createEventStore(eventsDbPath);
1518
+ const events = eventStore.getByAgent("custom-tool-agent");
1519
+ eventStore.close();
1520
+
1521
+ expect(events).toHaveLength(1);
1522
+ const event = events[0] as StoredEvent;
1523
+ expect(event.toolName).toBe("SomeCustomTool");
1524
+
1525
+ // Unknown tools get empty args from filterToolArgs
1526
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1527
+ expect(toolArgs).toEqual({});
1528
+
1529
+ const data = JSON.parse(event.data ?? "{}");
1530
+ expect(data.summary).toBe("SomeCustomTool");
1531
+ });
1532
+
1533
+ test("tool-end with --stdin handles large payloads (>64KB)", async () => {
1534
+ const payload = {
1535
+ tool_name: "Bash",
1536
+ tool_input: { command: "cat /some/file" },
1537
+ tool_result: "x".repeat(100_000), // 100KB payload
1538
+ session_id: "sess-large-payload",
1539
+ };
1540
+
1541
+ const result = await runLogWithStdin("tool-end", "large-payload-agent", payload);
1542
+ expect(result.exitCode).toBe(0);
1543
+
1544
+ // Verify EventStore received the event with correct tool name
1545
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1546
+ const eventStore = createEventStore(eventsDbPath);
1547
+ const events = eventStore.getByAgent("large-payload-agent");
1548
+ eventStore.close();
1549
+
1550
+ expect(events).toHaveLength(1);
1551
+ const event = events[0] as StoredEvent;
1552
+ expect(event.eventType).toBe("tool_end");
1553
+ expect(event.toolName).toBe("Bash");
1554
+ // tool_result is not stored in EventStore (filtered out), but tool_name was parsed correctly
1555
+ });
1556
+ });