@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * SQLite-backed event store for agent activity observability.
3
+ *
4
+ * Tracks tool invocations, session lifecycle, mail events, and errors.
5
+ * Uses bun:sqlite for zero-dependency, synchronous database access.
6
+ * WAL mode enables concurrent reads from multiple agent processes.
7
+ */
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import type {
11
+ EventLevel,
12
+ EventQueryOptions,
13
+ EventStore,
14
+ InsertEvent,
15
+ StoredEvent,
16
+ ToolStats,
17
+ } from "../types.ts";
18
+
19
+ /** Row shape as stored in SQLite (snake_case columns). */
20
+ interface EventRow {
21
+ id: number;
22
+ run_id: string | null;
23
+ agent_name: string;
24
+ session_id: string | null;
25
+ event_type: string;
26
+ tool_name: string | null;
27
+ tool_args: string | null;
28
+ tool_duration_ms: number | null;
29
+ level: string;
30
+ data: string | null;
31
+ created_at: string;
32
+ }
33
+
34
+ const CREATE_TABLE = `
35
+ CREATE TABLE IF NOT EXISTS events (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ run_id TEXT,
38
+ agent_name TEXT NOT NULL,
39
+ session_id TEXT,
40
+ event_type TEXT NOT NULL,
41
+ tool_name TEXT,
42
+ tool_args TEXT,
43
+ tool_duration_ms INTEGER,
44
+ level TEXT NOT NULL DEFAULT 'info' CHECK(level IN ('debug','info','warn','error')),
45
+ data TEXT,
46
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
47
+ )`;
48
+
49
+ const CREATE_INDEXES = `
50
+ CREATE INDEX IF NOT EXISTS idx_events_agent_time ON events(agent_name, created_at);
51
+ CREATE INDEX IF NOT EXISTS idx_events_run_time ON events(run_id, created_at);
52
+ CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, created_at);
53
+ CREATE INDEX IF NOT EXISTS idx_events_tool_agent ON events(tool_name, agent_name);
54
+ CREATE INDEX IF NOT EXISTS idx_events_level_error ON events(level) WHERE level = 'error'`;
55
+
56
+ /** Convert a database row (snake_case) to a StoredEvent object (camelCase). */
57
+ function rowToEvent(row: EventRow): StoredEvent {
58
+ return {
59
+ id: row.id,
60
+ runId: row.run_id,
61
+ agentName: row.agent_name,
62
+ sessionId: row.session_id,
63
+ eventType: row.event_type as StoredEvent["eventType"],
64
+ toolName: row.tool_name,
65
+ toolArgs: row.tool_args,
66
+ toolDurationMs: row.tool_duration_ms,
67
+ level: row.level as EventLevel,
68
+ data: row.data,
69
+ createdAt: row.created_at,
70
+ };
71
+ }
72
+
73
+ /** Build WHERE clause fragments and params from EventQueryOptions. */
74
+ function buildFilterClauses(
75
+ opts: EventQueryOptions | undefined,
76
+ existingConditions: string[] = [],
77
+ existingParams: Record<string, string | number> = {},
78
+ ): { whereClause: string; params: Record<string, string | number>; limitClause: string } {
79
+ const conditions = [...existingConditions];
80
+ const params = { ...existingParams };
81
+
82
+ if (opts?.since !== undefined) {
83
+ conditions.push("created_at >= $since");
84
+ params.$since = opts.since;
85
+ }
86
+ if (opts?.until !== undefined) {
87
+ conditions.push("created_at <= $until");
88
+ params.$until = opts.until;
89
+ }
90
+ if (opts?.level !== undefined) {
91
+ conditions.push("level = $level");
92
+ params.$level = opts.level;
93
+ }
94
+
95
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
96
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
97
+
98
+ return { whereClause, params, limitClause };
99
+ }
100
+
101
+ /**
102
+ * Create a new EventStore backed by a SQLite database at the given path.
103
+ *
104
+ * Initializes the database with WAL mode and a 5-second busy timeout.
105
+ * Creates the events table and indexes if they do not already exist.
106
+ */
107
+ export function createEventStore(dbPath: string): EventStore {
108
+ const db = new Database(dbPath);
109
+
110
+ // Configure for concurrent access from multiple agent processes.
111
+ db.exec("PRAGMA journal_mode = WAL");
112
+ db.exec("PRAGMA synchronous = NORMAL");
113
+ db.exec("PRAGMA busy_timeout = 5000");
114
+
115
+ // Create schema
116
+ db.exec(CREATE_TABLE);
117
+ db.exec(CREATE_INDEXES);
118
+
119
+ // Prepare the insert statement
120
+ const insertStmt = db.prepare<
121
+ { id: number },
122
+ {
123
+ $run_id: string | null;
124
+ $agent_name: string;
125
+ $session_id: string | null;
126
+ $event_type: string;
127
+ $tool_name: string | null;
128
+ $tool_args: string | null;
129
+ $tool_duration_ms: number | null;
130
+ $level: string;
131
+ $data: string | null;
132
+ }
133
+ >(`
134
+ INSERT INTO events
135
+ (run_id, agent_name, session_id, event_type, tool_name, tool_args, tool_duration_ms, level, data)
136
+ VALUES
137
+ ($run_id, $agent_name, $session_id, $event_type, $tool_name, $tool_args, $tool_duration_ms, $level, $data)
138
+ RETURNING id
139
+ `);
140
+
141
+ // Prepare correlateToolEnd: find the most recent tool_start for this agent+tool
142
+ // that has no corresponding tool_end yet (no tool_duration_ms set).
143
+ const correlateStmt = db.prepare<
144
+ { id: number; created_at: string },
145
+ { $agent_name: string; $tool_name: string }
146
+ >(`
147
+ SELECT id, created_at FROM events
148
+ WHERE agent_name = $agent_name
149
+ AND tool_name = $tool_name
150
+ AND event_type = 'tool_start'
151
+ AND tool_duration_ms IS NULL
152
+ ORDER BY created_at DESC
153
+ LIMIT 1
154
+ `);
155
+
156
+ const updateDurationStmt = db.prepare<void, { $id: number; $duration_ms: number }>(`
157
+ UPDATE events SET tool_duration_ms = $duration_ms WHERE id = $id
158
+ `);
159
+
160
+ // Prepare getByAgent
161
+ const byAgentStmt = db.prepare<EventRow, { $agent_name: string }>(`
162
+ SELECT * FROM events WHERE agent_name = $agent_name ORDER BY created_at ASC
163
+ `);
164
+
165
+ // Prepare getByRun
166
+ const byRunStmt = db.prepare<EventRow, { $run_id: string }>(`
167
+ SELECT * FROM events WHERE run_id = $run_id ORDER BY created_at ASC
168
+ `);
169
+
170
+ return {
171
+ insert(event: InsertEvent): number {
172
+ const row = insertStmt.get({
173
+ $run_id: event.runId,
174
+ $agent_name: event.agentName,
175
+ $session_id: event.sessionId,
176
+ $event_type: event.eventType,
177
+ $tool_name: event.toolName,
178
+ $tool_args: event.toolArgs,
179
+ $tool_duration_ms: event.toolDurationMs,
180
+ $level: event.level,
181
+ $data: event.data,
182
+ });
183
+ // RETURNING id always returns a row for INSERT; if somehow null, fallback to 0
184
+ if (!row) {
185
+ return 0;
186
+ }
187
+ return row.id;
188
+ },
189
+
190
+ correlateToolEnd(
191
+ agentName: string,
192
+ toolName: string,
193
+ ): { startId: number; durationMs: number } | null {
194
+ const startRow = correlateStmt.get({
195
+ $agent_name: agentName,
196
+ $tool_name: toolName,
197
+ });
198
+
199
+ if (!startRow) {
200
+ return null;
201
+ }
202
+
203
+ const startTime = new Date(startRow.created_at).getTime();
204
+ const durationMs = Date.now() - startTime;
205
+
206
+ // Mark the start event with the computed duration
207
+ updateDurationStmt.run({
208
+ $id: startRow.id,
209
+ $duration_ms: durationMs,
210
+ });
211
+
212
+ return { startId: startRow.id, durationMs };
213
+ },
214
+
215
+ getByAgent(agentName: string, opts?: EventQueryOptions): StoredEvent[] {
216
+ if (
217
+ opts !== undefined &&
218
+ (opts.since !== undefined ||
219
+ opts.until !== undefined ||
220
+ opts.level !== undefined ||
221
+ opts.limit !== undefined)
222
+ ) {
223
+ // Use dynamic query with filters
224
+ const { whereClause, params, limitClause } = buildFilterClauses(
225
+ opts,
226
+ ["agent_name = $agent_name"],
227
+ { $agent_name: agentName },
228
+ );
229
+ const query = `SELECT * FROM events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
230
+ const rows = db.prepare<EventRow, Record<string, string | number>>(query).all(params);
231
+ return rows.map(rowToEvent);
232
+ }
233
+ const rows = byAgentStmt.all({ $agent_name: agentName });
234
+ return rows.map(rowToEvent);
235
+ },
236
+
237
+ getByRun(runId: string, opts?: EventQueryOptions): StoredEvent[] {
238
+ if (
239
+ opts !== undefined &&
240
+ (opts.since !== undefined ||
241
+ opts.until !== undefined ||
242
+ opts.level !== undefined ||
243
+ opts.limit !== undefined)
244
+ ) {
245
+ const { whereClause, params, limitClause } = buildFilterClauses(
246
+ opts,
247
+ ["run_id = $run_id"],
248
+ { $run_id: runId },
249
+ );
250
+ const query = `SELECT * FROM events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
251
+ const rows = db.prepare<EventRow, Record<string, string | number>>(query).all(params);
252
+ return rows.map(rowToEvent);
253
+ }
254
+ const rows = byRunStmt.all({ $run_id: runId });
255
+ return rows.map(rowToEvent);
256
+ },
257
+
258
+ getErrors(opts?: EventQueryOptions): StoredEvent[] {
259
+ const { whereClause, params, limitClause } = buildFilterClauses(opts, ["level = 'error'"]);
260
+ const query = `SELECT * FROM events ${whereClause} ORDER BY created_at DESC ${limitClause}`;
261
+ const rows = db.prepare<EventRow, Record<string, string | number>>(query).all(params);
262
+ return rows.map(rowToEvent);
263
+ },
264
+
265
+ getTimeline(opts: EventQueryOptions & { since: string }): StoredEvent[] {
266
+ const { whereClause, params, limitClause } = buildFilterClauses(opts);
267
+ const query = `SELECT * FROM events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
268
+ const rows = db.prepare<EventRow, Record<string, string | number>>(query).all(params);
269
+ return rows.map(rowToEvent);
270
+ },
271
+
272
+ getToolStats(opts?: { agentName?: string; since?: string }): ToolStats[] {
273
+ const conditions: string[] = ["tool_name IS NOT NULL", "event_type = 'tool_start'"];
274
+ const params: Record<string, string> = {};
275
+
276
+ if (opts?.agentName !== undefined) {
277
+ conditions.push("agent_name = $agent_name");
278
+ params.$agent_name = opts.agentName;
279
+ }
280
+ if (opts?.since !== undefined) {
281
+ conditions.push("created_at >= $since");
282
+ params.$since = opts.since;
283
+ }
284
+
285
+ const whereClause = `WHERE ${conditions.join(" AND ")}`;
286
+ const query = `
287
+ SELECT
288
+ tool_name,
289
+ COUNT(*) AS count,
290
+ COALESCE(AVG(tool_duration_ms), 0) AS avg_duration_ms,
291
+ COALESCE(MAX(tool_duration_ms), 0) AS max_duration_ms
292
+ FROM events
293
+ ${whereClause}
294
+ GROUP BY tool_name
295
+ ORDER BY count DESC
296
+ `;
297
+ const rows = db
298
+ .prepare<
299
+ {
300
+ tool_name: string;
301
+ count: number;
302
+ avg_duration_ms: number;
303
+ max_duration_ms: number;
304
+ },
305
+ Record<string, string>
306
+ >(query)
307
+ .all(params);
308
+
309
+ return rows.map((row) => ({
310
+ toolName: row.tool_name,
311
+ count: row.count,
312
+ avgDurationMs: row.avg_duration_ms,
313
+ maxDurationMs: row.max_duration_ms,
314
+ }));
315
+ },
316
+
317
+ purge(opts: { all?: boolean; olderThanMs?: number; agentName?: string }): number {
318
+ if (opts.all) {
319
+ const countRow = db
320
+ .prepare<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM events")
321
+ .get();
322
+ const count = countRow?.cnt ?? 0;
323
+ db.prepare("DELETE FROM events").run();
324
+ return count;
325
+ }
326
+
327
+ const conditions: string[] = [];
328
+ const params: Record<string, string> = {};
329
+
330
+ if (opts.olderThanMs !== undefined) {
331
+ const cutoff = new Date(Date.now() - opts.olderThanMs).toISOString();
332
+ conditions.push("created_at < $cutoff");
333
+ params.$cutoff = cutoff;
334
+ }
335
+
336
+ if (opts.agentName !== undefined) {
337
+ conditions.push("agent_name = $agent_name");
338
+ params.$agent_name = opts.agentName;
339
+ }
340
+
341
+ if (conditions.length === 0) {
342
+ return 0;
343
+ }
344
+
345
+ const whereClause = conditions.join(" AND ");
346
+ const countRow = db
347
+ .prepare<{ cnt: number }, Record<string, string>>(
348
+ `SELECT COUNT(*) as cnt FROM events WHERE ${whereClause}`,
349
+ )
350
+ .get(params);
351
+ const count = countRow?.cnt ?? 0;
352
+
353
+ db.prepare<void, Record<string, string>>(`DELETE FROM events WHERE ${whereClause}`).run(
354
+ params,
355
+ );
356
+
357
+ return count;
358
+ },
359
+
360
+ close(): void {
361
+ try {
362
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
363
+ } catch {
364
+ // Best effort -- checkpoint failure is non-fatal
365
+ }
366
+ db.close();
367
+ },
368
+ };
369
+ }
@@ -0,0 +1,330 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { filterToolArgs } from "./tool-filter.ts";
3
+
4
+ describe("filterToolArgs", () => {
5
+ describe("Bash", () => {
6
+ test("keeps command and description, drops timeout/run_in_background/dangerouslyDisableSandbox", () => {
7
+ const result = filterToolArgs("Bash", {
8
+ command: "bun test",
9
+ description: "Run tests",
10
+ timeout: 60000,
11
+ run_in_background: true,
12
+ dangerouslyDisableSandbox: false,
13
+ });
14
+ expect(result.args).toEqual({
15
+ command: "bun test",
16
+ description: "Run tests",
17
+ });
18
+ expect(result.args).not.toHaveProperty("timeout");
19
+ expect(result.args).not.toHaveProperty("run_in_background");
20
+ expect(result.args).not.toHaveProperty("dangerouslyDisableSandbox");
21
+ });
22
+
23
+ test("summary shows first 80 chars of command", () => {
24
+ const result = filterToolArgs("Bash", { command: "bun test" });
25
+ expect(result.summary).toBe("bash: bun test");
26
+ });
27
+
28
+ test("summary truncates long commands at 80 chars", () => {
29
+ const longCmd = "a".repeat(120);
30
+ const result = filterToolArgs("Bash", { command: longCmd });
31
+ expect(result.summary).toBe(`bash: ${"a".repeat(80)}...`);
32
+ });
33
+
34
+ test("handles missing command gracefully", () => {
35
+ const result = filterToolArgs("Bash", {});
36
+ expect(result.args).toEqual({});
37
+ expect(result.summary).toBe("bash: ");
38
+ });
39
+ });
40
+
41
+ describe("Read", () => {
42
+ test("keeps file_path, offset, limit", () => {
43
+ const result = filterToolArgs("Read", {
44
+ file_path: "/src/index.ts",
45
+ offset: 10,
46
+ limit: 50,
47
+ });
48
+ expect(result.args).toEqual({
49
+ file_path: "/src/index.ts",
50
+ offset: 10,
51
+ limit: 50,
52
+ });
53
+ });
54
+
55
+ test("summary with offset and limit shows line range", () => {
56
+ const result = filterToolArgs("Read", {
57
+ file_path: "/src/index.ts",
58
+ offset: 10,
59
+ limit: 50,
60
+ });
61
+ expect(result.summary).toBe("read: /src/index.ts (lines 10-60)");
62
+ });
63
+
64
+ test("summary with only offset shows from line", () => {
65
+ const result = filterToolArgs("Read", {
66
+ file_path: "/src/index.ts",
67
+ offset: 10,
68
+ });
69
+ expect(result.summary).toBe("read: /src/index.ts (from line 10)");
70
+ });
71
+
72
+ test("summary with only limit shows first N lines", () => {
73
+ const result = filterToolArgs("Read", {
74
+ file_path: "/src/index.ts",
75
+ limit: 50,
76
+ });
77
+ expect(result.summary).toBe("read: /src/index.ts (first 50 lines)");
78
+ });
79
+
80
+ test("summary without offset or limit shows just path", () => {
81
+ const result = filterToolArgs("Read", {
82
+ file_path: "/src/index.ts",
83
+ });
84
+ expect(result.summary).toBe("read: /src/index.ts");
85
+ });
86
+
87
+ test("handles missing file_path", () => {
88
+ const result = filterToolArgs("Read", {});
89
+ expect(result.summary).toBe("read: ");
90
+ });
91
+ });
92
+
93
+ describe("Write", () => {
94
+ test("keeps file_path, drops content", () => {
95
+ const result = filterToolArgs("Write", {
96
+ file_path: "/src/index.ts",
97
+ content: "const x = 1;\nconst y = 2;\n// lots of content...",
98
+ });
99
+ expect(result.args).toEqual({ file_path: "/src/index.ts" });
100
+ expect(result.args).not.toHaveProperty("content");
101
+ });
102
+
103
+ test("summary shows file path", () => {
104
+ const result = filterToolArgs("Write", {
105
+ file_path: "/src/index.ts",
106
+ content: "stuff",
107
+ });
108
+ expect(result.summary).toBe("write: /src/index.ts");
109
+ });
110
+
111
+ test("handles missing file_path", () => {
112
+ const result = filterToolArgs("Write", { content: "data" });
113
+ expect(result.args).toEqual({});
114
+ expect(result.summary).toBe("write: ");
115
+ });
116
+ });
117
+
118
+ describe("Edit", () => {
119
+ test("keeps file_path, drops old_string and new_string", () => {
120
+ const result = filterToolArgs("Edit", {
121
+ file_path: "/src/config.ts",
122
+ old_string: "const x = 1;",
123
+ new_string: "const x = 2;",
124
+ });
125
+ expect(result.args).toEqual({ file_path: "/src/config.ts" });
126
+ expect(result.args).not.toHaveProperty("old_string");
127
+ expect(result.args).not.toHaveProperty("new_string");
128
+ });
129
+
130
+ test("summary shows file path", () => {
131
+ const result = filterToolArgs("Edit", {
132
+ file_path: "/src/config.ts",
133
+ old_string: "a",
134
+ new_string: "b",
135
+ });
136
+ expect(result.summary).toBe("edit: /src/config.ts");
137
+ });
138
+ });
139
+
140
+ describe("Glob", () => {
141
+ test("keeps pattern and path", () => {
142
+ const result = filterToolArgs("Glob", {
143
+ pattern: "**/*.ts",
144
+ path: "/src",
145
+ });
146
+ expect(result.args).toEqual({ pattern: "**/*.ts", path: "/src" });
147
+ });
148
+
149
+ test("summary with path shows pattern in path", () => {
150
+ const result = filterToolArgs("Glob", {
151
+ pattern: "**/*.ts",
152
+ path: "/src",
153
+ });
154
+ expect(result.summary).toBe("glob: **/*.ts in /src");
155
+ });
156
+
157
+ test("summary without path shows only pattern", () => {
158
+ const result = filterToolArgs("Glob", { pattern: "**/*.ts" });
159
+ expect(result.summary).toBe("glob: **/*.ts");
160
+ });
161
+ });
162
+
163
+ describe("Grep", () => {
164
+ test("keeps pattern, path, glob, output_mode", () => {
165
+ const result = filterToolArgs("Grep", {
166
+ pattern: "function\\s+\\w+",
167
+ path: "/src",
168
+ glob: "*.ts",
169
+ output_mode: "content",
170
+ "-A": 3,
171
+ "-B": 2,
172
+ });
173
+ expect(result.args).toEqual({
174
+ pattern: "function\\s+\\w+",
175
+ path: "/src",
176
+ glob: "*.ts",
177
+ output_mode: "content",
178
+ });
179
+ expect(result.args).not.toHaveProperty("-A");
180
+ expect(result.args).not.toHaveProperty("-B");
181
+ });
182
+
183
+ test("summary with path shows pattern in path", () => {
184
+ const result = filterToolArgs("Grep", {
185
+ pattern: "TODO",
186
+ path: "/src",
187
+ });
188
+ expect(result.summary).toBe('grep: "TODO" in /src');
189
+ });
190
+
191
+ test("summary without path shows only pattern", () => {
192
+ const result = filterToolArgs("Grep", { pattern: "TODO" });
193
+ expect(result.summary).toBe('grep: "TODO"');
194
+ });
195
+ });
196
+
197
+ describe("WebFetch", () => {
198
+ test("keeps url, drops prompt", () => {
199
+ const result = filterToolArgs("WebFetch", {
200
+ url: "https://example.com/page",
201
+ prompt: "Extract the main content from this page",
202
+ });
203
+ expect(result.args).toEqual({ url: "https://example.com/page" });
204
+ expect(result.args).not.toHaveProperty("prompt");
205
+ });
206
+
207
+ test("summary shows url", () => {
208
+ const result = filterToolArgs("WebFetch", {
209
+ url: "https://example.com",
210
+ });
211
+ expect(result.summary).toBe("fetch: https://example.com");
212
+ });
213
+ });
214
+
215
+ describe("WebSearch", () => {
216
+ test("keeps query, drops domain filters", () => {
217
+ const result = filterToolArgs("WebSearch", {
218
+ query: "TypeScript strict mode",
219
+ allowed_domains: ["developer.mozilla.org"],
220
+ blocked_domains: ["w3schools.com"],
221
+ });
222
+ expect(result.args).toEqual({ query: "TypeScript strict mode" });
223
+ expect(result.args).not.toHaveProperty("allowed_domains");
224
+ expect(result.args).not.toHaveProperty("blocked_domains");
225
+ });
226
+
227
+ test("summary shows query", () => {
228
+ const result = filterToolArgs("WebSearch", {
229
+ query: "bun test runner",
230
+ });
231
+ expect(result.summary).toBe("search: bun test runner");
232
+ });
233
+ });
234
+
235
+ describe("Task", () => {
236
+ test("keeps description and subagent_type", () => {
237
+ const result = filterToolArgs("Task", {
238
+ description: "Analyze the codebase structure",
239
+ subagent_type: "research",
240
+ prompt: "Look at all the files and determine...",
241
+ });
242
+ expect(result.args).toEqual({
243
+ description: "Analyze the codebase structure",
244
+ subagent_type: "research",
245
+ });
246
+ expect(result.args).not.toHaveProperty("prompt");
247
+ });
248
+
249
+ test("summary with subagent_type shows description and type", () => {
250
+ const result = filterToolArgs("Task", {
251
+ description: "Find all config files",
252
+ subagent_type: "research",
253
+ });
254
+ expect(result.summary).toBe("task: Find all config files (research)");
255
+ });
256
+
257
+ test("summary without subagent_type shows only description", () => {
258
+ const result = filterToolArgs("Task", {
259
+ description: "Find all config files",
260
+ });
261
+ expect(result.summary).toBe("task: Find all config files");
262
+ });
263
+ });
264
+
265
+ describe("unknown tools", () => {
266
+ test("returns empty args and tool name as summary", () => {
267
+ const result = filterToolArgs("SomeUnknownTool", {
268
+ foo: "bar",
269
+ baz: 42,
270
+ });
271
+ expect(result.args).toEqual({});
272
+ expect(result.summary).toBe("SomeUnknownTool");
273
+ });
274
+
275
+ test("handles empty input for unknown tool", () => {
276
+ const result = filterToolArgs("Mystery", {});
277
+ expect(result.args).toEqual({});
278
+ expect(result.summary).toBe("Mystery");
279
+ });
280
+ });
281
+
282
+ describe("edge cases", () => {
283
+ test("handles empty input object for known tool", () => {
284
+ const result = filterToolArgs("Bash", {});
285
+ expect(result.args).toEqual({});
286
+ expect(result.summary).toBe("bash: ");
287
+ });
288
+
289
+ test("handles null values in input", () => {
290
+ const result = filterToolArgs("Read", {
291
+ file_path: null as unknown as string,
292
+ offset: null as unknown as number,
293
+ });
294
+ // null is not undefined, so file_path will be picked but summary treats it as non-string
295
+ expect(result.args).toHaveProperty("file_path");
296
+ expect(result.summary).toBe("read: ");
297
+ });
298
+
299
+ test("handles undefined values in input", () => {
300
+ const result = filterToolArgs("Read", {
301
+ file_path: undefined as unknown as string,
302
+ });
303
+ // undefined values should not appear in filtered args
304
+ expect(result.args).not.toHaveProperty("file_path");
305
+ expect(result.summary).toBe("read: ");
306
+ });
307
+
308
+ test("handles numeric values where strings expected", () => {
309
+ const result = filterToolArgs("Bash", {
310
+ command: 42 as unknown as string,
311
+ });
312
+ // Value is kept in args as-is, but summary treats non-strings as empty
313
+ expect(result.args).toEqual({ command: 42 });
314
+ expect(result.summary).toBe("bash: ");
315
+ });
316
+
317
+ test("preserves exact 80-char command without truncation", () => {
318
+ const cmd = "x".repeat(80);
319
+ const result = filterToolArgs("Bash", { command: cmd });
320
+ expect(result.summary).toBe(`bash: ${cmd}`);
321
+ expect(result.summary).not.toContain("...");
322
+ });
323
+
324
+ test("truncates 81-char command", () => {
325
+ const cmd = "y".repeat(81);
326
+ const result = filterToolArgs("Bash", { command: cmd });
327
+ expect(result.summary).toBe(`bash: ${"y".repeat(80)}...`);
328
+ });
329
+ });
330
+ });