opencode-swarm-plugin 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # opencode-swarm-plugin
2
2
 
3
+ ## 0.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`084f888`](https://github.com/joelhooks/swarm-tools/commit/084f888fcac4912f594428b1ac7148c8a8aaa422) Thanks [@joelhooks](https://github.com/joelhooks)! - ## 👁️ Watch Your Swarm in Real-Time
8
+
9
+ `swarm log` now has a `--watch` mode for continuous log monitoring. No more running the command repeatedly - just sit back and watch the bees work.
10
+
11
+ ```bash
12
+ # Watch all logs
13
+ swarm log --watch
14
+
15
+ # Watch with filters
16
+ swarm log compaction -w --level error
17
+
18
+ # Faster polling (500ms instead of default 1s)
19
+ swarm log --watch --interval 500
20
+ ```
21
+
22
+ **New flags:**
23
+
24
+ - `--watch`, `-w` - Enable continuous monitoring mode
25
+ - `--interval <ms>` - Poll interval in milliseconds (default: 1000, min: 100)
26
+
27
+ **How it works:**
28
+
29
+ - Shows initial logs (last N lines based on `--limit`)
30
+ - Polls log files for new entries at the specified interval
31
+ - Tracks file positions for efficient incremental reads
32
+ - Handles log rotation gracefully (detects file truncation)
33
+ - All existing filters work: `--level`, `--since`, module name
34
+ - Clean shutdown on Ctrl+C
35
+
36
+ _"The hive that watches itself, debugs itself."_
37
+
3
38
  ## 0.34.0
4
39
 
5
40
  ### Minor Changes
package/bin/swarm.test.ts CHANGED
@@ -462,4 +462,74 @@ describe("Log command helpers", () => {
462
462
  expect(lines).toHaveLength(0);
463
463
  });
464
464
  });
465
+
466
+ describe("watchLogs", () => {
467
+ test("detects new log lines appended to file", async () => {
468
+ const logFile = join(testDir, "swarm.1log");
469
+ const collectedLines: string[] = [];
470
+
471
+ // Create initial log file
472
+ writeFileSync(logFile, '{"level":30,"time":"2024-12-24T16:00:00.000Z","msg":"initial"}\n');
473
+
474
+ // Import watch utilities
475
+ const { watch } = await import("fs");
476
+ const { appendFileSync } = await import("fs");
477
+
478
+ // Track file position for incremental reads
479
+ let lastSize = 0;
480
+
481
+ function readNewLines(filePath: string): string[] {
482
+ const content = readFileSync(filePath, "utf-8");
483
+ const newContent = content.slice(lastSize);
484
+ lastSize = content.length;
485
+ return newContent.split("\n").filter((line) => line.trim());
486
+ }
487
+
488
+ // Simulate watch behavior
489
+ const watcher = watch(testDir, (eventType, filename) => {
490
+ if (filename && /\.\d+log$/.test(filename)) {
491
+ const newLines = readNewLines(join(testDir, filename));
492
+ collectedLines.push(...newLines);
493
+ }
494
+ });
495
+
496
+ // Wait for watcher to be ready
497
+ await new Promise((resolve) => setTimeout(resolve, 100));
498
+
499
+ // Append new log line
500
+ appendFileSync(logFile, '{"level":30,"time":"2024-12-24T16:00:01.000Z","msg":"appended"}\n');
501
+
502
+ // Wait for event to fire
503
+ await new Promise((resolve) => setTimeout(resolve, 200));
504
+
505
+ watcher.close();
506
+
507
+ // Should have detected the new line
508
+ expect(collectedLines.some((l) => l.includes("appended"))).toBe(true);
509
+ });
510
+
511
+ test("parseWatchArgs extracts --watch flag", () => {
512
+ function parseWatchArgs(args: string[]): { watch: boolean; interval: number } {
513
+ let watch = false;
514
+ let interval = 1000; // default 1 second
515
+
516
+ for (let i = 0; i < args.length; i++) {
517
+ const arg = args[i];
518
+ if (arg === "--watch" || arg === "-w") {
519
+ watch = true;
520
+ } else if (arg === "--interval" && i + 1 < args.length) {
521
+ interval = parseInt(args[++i], 10);
522
+ }
523
+ }
524
+
525
+ return { watch, interval };
526
+ }
527
+
528
+ expect(parseWatchArgs(["--watch"])).toEqual({ watch: true, interval: 1000 });
529
+ expect(parseWatchArgs(["-w"])).toEqual({ watch: true, interval: 1000 });
530
+ expect(parseWatchArgs(["--watch", "--interval", "500"])).toEqual({ watch: true, interval: 500 });
531
+ expect(parseWatchArgs(["compaction", "--watch"])).toEqual({ watch: true, interval: 1000 });
532
+ expect(parseWatchArgs(["--level", "error"])).toEqual({ watch: false, interval: 1000 });
533
+ });
534
+ });
465
535
  });
package/bin/swarm.ts CHANGED
@@ -2732,6 +2732,8 @@ ${cyan("Log Viewing:")}
2732
2732
  swarm log --since <duration> Time filter (30s, 5m, 2h, 1d)
2733
2733
  swarm log --json Raw JSON output for jq
2734
2734
  swarm log --limit <n> Limit output to n lines (default: 50)
2735
+ swarm log --watch, -w Watch mode - continuously monitor for new logs
2736
+ swarm log --interval <ms> Poll interval in ms (default: 1000, min: 100)
2735
2737
 
2736
2738
  ${cyan("Usage in OpenCode:")}
2737
2739
  /swarm "Add user authentication with OAuth"
@@ -3203,6 +3205,8 @@ async function logs() {
3203
3205
  let sinceMs: number | null = null;
3204
3206
  let jsonOutput = false;
3205
3207
  let limit = 50;
3208
+ let watchMode = false;
3209
+ let pollInterval = 1000; // 1 second default
3206
3210
 
3207
3211
  for (let i = 0; i < args.length; i++) {
3208
3212
  const arg = args[i];
@@ -3225,7 +3229,15 @@ async function logs() {
3225
3229
  p.log.error(`Invalid limit: ${args[i]}`);
3226
3230
  process.exit(1);
3227
3231
  }
3228
- } else if (!arg.startsWith("--")) {
3232
+ } else if (arg === "--watch" || arg === "-w") {
3233
+ watchMode = true;
3234
+ } else if (arg === "--interval" && i + 1 < args.length) {
3235
+ pollInterval = parseInt(args[++i], 10);
3236
+ if (isNaN(pollInterval) || pollInterval < 100) {
3237
+ p.log.error(`Invalid interval: ${args[i]} (minimum 100ms)`);
3238
+ process.exit(1);
3239
+ }
3240
+ } else if (!arg.startsWith("--") && !arg.startsWith("-")) {
3229
3241
  // Positional arg = module filter
3230
3242
  moduleFilter = arg;
3231
3243
  }
@@ -3244,6 +3256,131 @@ async function logs() {
3244
3256
  return;
3245
3257
  }
3246
3258
 
3259
+ // Helper to filter logs
3260
+ const filterLogs = (rawLogs: LogLine[]): LogLine[] => {
3261
+ let filtered = rawLogs;
3262
+
3263
+ if (moduleFilter) {
3264
+ filtered = filtered.filter((log) => log.module === moduleFilter);
3265
+ }
3266
+
3267
+ if (levelFilter !== null) {
3268
+ filtered = filtered.filter((log) => log.level >= levelFilter);
3269
+ }
3270
+
3271
+ if (sinceMs !== null) {
3272
+ const cutoffTime = Date.now() - sinceMs;
3273
+ filtered = filtered.filter((log) => new Date(log.time).getTime() >= cutoffTime);
3274
+ }
3275
+
3276
+ return filtered;
3277
+ };
3278
+
3279
+ // Watch mode - continuous monitoring
3280
+ if (watchMode) {
3281
+ console.log(yellow(BANNER));
3282
+ console.log(dim(` Watching logs... (Ctrl+C to stop)`));
3283
+ if (moduleFilter) console.log(dim(` Module: ${moduleFilter}`));
3284
+ if (levelFilter !== null) console.log(dim(` Level: >=${levelToName(levelFilter)}`));
3285
+ console.log();
3286
+
3287
+ // Track file positions for incremental reads
3288
+ const filePositions: Map<string, number> = new Map();
3289
+
3290
+ // Initialize positions from current file sizes
3291
+ const initializePositions = () => {
3292
+ if (!existsSync(logsDir)) return;
3293
+ const files = readdirSync(logsDir).filter((f: string) => /\.\d+log$/.test(f));
3294
+ for (const file of files) {
3295
+ const filePath = join(logsDir, file);
3296
+ try {
3297
+ const stats = statSync(filePath);
3298
+ filePositions.set(filePath, stats.size);
3299
+ } catch {
3300
+ // Skip unreadable files
3301
+ }
3302
+ }
3303
+ };
3304
+
3305
+ // Read new lines from a file since last position
3306
+ const readNewLines = (filePath: string): string[] => {
3307
+ try {
3308
+ const stats = statSync(filePath);
3309
+ const lastPos = filePositions.get(filePath) || 0;
3310
+
3311
+ if (stats.size <= lastPos) {
3312
+ // File was truncated or no new content
3313
+ if (stats.size < lastPos) {
3314
+ filePositions.set(filePath, stats.size);
3315
+ }
3316
+ return [];
3317
+ }
3318
+
3319
+ const content = readFileSync(filePath, "utf-8");
3320
+ const newContent = content.slice(lastPos);
3321
+ filePositions.set(filePath, stats.size);
3322
+
3323
+ return newContent.split("\n").filter((line: string) => line.trim());
3324
+ } catch {
3325
+ return [];
3326
+ }
3327
+ };
3328
+
3329
+ // Print initial logs (last N lines)
3330
+ const rawLines = readLogFiles(logsDir);
3331
+ let logs: LogLine[] = rawLines
3332
+ .map(parseLogLine)
3333
+ .filter((log): log is LogLine => log !== null);
3334
+ logs = filterLogs(logs).slice(-limit);
3335
+
3336
+ for (const log of logs) {
3337
+ console.log(formatLogLine(log));
3338
+ }
3339
+
3340
+ // Initialize positions after printing initial logs
3341
+ initializePositions();
3342
+
3343
+ // Poll for new logs
3344
+ const pollForNewLogs = () => {
3345
+ if (!existsSync(logsDir)) return;
3346
+
3347
+ const files = readdirSync(logsDir).filter((f: string) => /\.\d+log$/.test(f));
3348
+
3349
+ for (const file of files) {
3350
+ const filePath = join(logsDir, file);
3351
+ const newLines = readNewLines(filePath);
3352
+
3353
+ for (const line of newLines) {
3354
+ const parsed = parseLogLine(line);
3355
+ if (parsed) {
3356
+ const filtered = filterLogs([parsed]);
3357
+ if (filtered.length > 0) {
3358
+ console.log(formatLogLine(filtered[0]));
3359
+ }
3360
+ }
3361
+ }
3362
+ }
3363
+ };
3364
+
3365
+ // Set up polling interval
3366
+ const intervalId = setInterval(pollForNewLogs, pollInterval);
3367
+
3368
+ // Handle graceful shutdown
3369
+ const cleanup = () => {
3370
+ clearInterval(intervalId);
3371
+ console.log(dim("\n Stopped watching."));
3372
+ process.exit(0);
3373
+ };
3374
+
3375
+ process.on("SIGINT", cleanup);
3376
+ process.on("SIGTERM", cleanup);
3377
+
3378
+ // Keep process alive
3379
+ await new Promise(() => {});
3380
+ return;
3381
+ }
3382
+
3383
+ // Non-watch mode - one-shot output
3247
3384
  const rawLines = readLogFiles(logsDir);
3248
3385
 
3249
3386
  // Parse and filter
@@ -3251,19 +3388,7 @@ async function logs() {
3251
3388
  .map(parseLogLine)
3252
3389
  .filter((log): log is LogLine => log !== null);
3253
3390
 
3254
- // Apply filters
3255
- if (moduleFilter) {
3256
- logs = logs.filter((log) => log.module === moduleFilter);
3257
- }
3258
-
3259
- if (levelFilter !== null) {
3260
- logs = logs.filter((log) => log.level >= levelFilter);
3261
- }
3262
-
3263
- if (sinceMs !== null) {
3264
- const cutoffTime = Date.now() - sinceMs;
3265
- logs = logs.filter((log) => new Date(log.time).getTime() >= cutoffTime);
3266
- }
3391
+ logs = filterLogs(logs);
3267
3392
 
3268
3393
  // Apply limit (keep most recent)
3269
3394
  logs = logs.slice(-limit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm-plugin",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Multi-agent swarm coordination for OpenCode with learning capabilities, beads integration, and Agent Mail",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -330,254 +330,276 @@ describe("Compaction Hook", () => {
330
330
  });
331
331
  });
332
332
 
333
- describe("scanSessionMessages (SDK client integration)", () => {
334
- /**
335
- * These tests verify that we can extract swarm state from session messages
336
- * using the OpenCode SDK client.
337
- *
338
- * Key types from @opencode-ai/sdk:
339
- * - ToolPart: { type: "tool", tool: string, state: ToolState, ... }
340
- * - ToolStateCompleted: { status: "completed", input: Record<string, unknown>, output: string, ... }
341
- * - Part: union type including ToolPart
342
- * - Message: { id, sessionID, role, ... }
343
- *
344
- * SDK API:
345
- * - client.session.messages({ sessionID, limit }) → { info: Message, parts: Part[] }[]
346
- */
347
-
348
- // Mock SDK client factory
349
- const createMockClient = (messages: Array<{ info: any; parts: any[] }>) => ({
350
- session: {
351
- messages: async ({ sessionID, limit }: { sessionID: string; limit?: number }) => ({
352
- data: messages,
353
- error: undefined,
354
- }),
355
- },
333
+ describe("scanSessionMessages", () => {
334
+ it("returns empty state when client is undefined", async () => {
335
+ const { scanSessionMessages } = await import("./compaction-hook");
336
+
337
+ const result = await scanSessionMessages(undefined, "session-123");
338
+
339
+ expect(result.epicId).toBeUndefined();
340
+ expect(result.epicTitle).toBeUndefined();
341
+ expect(result.projectPath).toBeUndefined();
342
+ expect(result.agentName).toBeUndefined();
343
+ expect(result.subtasks.size).toBe(0);
344
+ expect(result.lastAction).toBeUndefined();
356
345
  });
357
346
 
358
- it("extracts epic ID from swarm_spawn_subtask tool calls", async () => {
359
- const mockMessages = [
360
- {
361
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
362
- parts: [
363
- {
364
- id: "part-1",
365
- type: "tool",
366
- tool: "swarm_spawn_subtask",
367
- state: {
368
- status: "completed",
369
- input: {
370
- epic_id: "bd-epic-auth-123",
371
- subtask_title: "Implement auth service",
372
- files: ["src/auth/service.ts"],
373
- },
374
- output: JSON.stringify({ success: true, agent_name: "BlueLake" }),
375
- title: "Spawned subtask",
376
- metadata: {},
377
- time: { start: 1000, end: 2000 },
347
+ it("extracts epic data from hive_create_epic tool call", async () => {
348
+ const { scanSessionMessages } = await import("./compaction-hook");
349
+
350
+ // Mock SDK client
351
+ const mockClient = {
352
+ session: {
353
+ messages: async ({ sessionID, limit }: { sessionID: string; limit?: number }) => {
354
+ return [
355
+ {
356
+ info: { id: "msg-1", sessionID: "session-123" },
357
+ parts: [
358
+ {
359
+ id: "part-1",
360
+ sessionID: "session-123",
361
+ messageID: "msg-1",
362
+ type: "tool" as const,
363
+ callID: "call-1",
364
+ tool: "hive_create_epic",
365
+ state: {
366
+ status: "completed" as const,
367
+ input: {
368
+ epic_title: "Add authentication system",
369
+ epic_description: "Implement OAuth flow",
370
+ },
371
+ output: JSON.stringify({
372
+ success: true,
373
+ epic: { id: "bd-epic-123" },
374
+ }),
375
+ title: "Create Epic",
376
+ metadata: {},
377
+ time: { start: Date.now(), end: Date.now() },
378
+ },
379
+ },
380
+ ],
378
381
  },
379
- },
380
- ],
382
+ ];
383
+ },
381
384
  },
382
- ];
383
-
384
- const client = createMockClient(mockMessages);
385
+ } as any;
385
386
 
386
- // This function doesn't exist yet - TDD Red phase
387
- const { scanSessionMessages } = await import("./compaction-hook");
388
- const result = await scanSessionMessages(client as any, "sess-1");
389
-
390
- expect(result.epicId).toBe("bd-epic-auth-123");
391
- expect(result.subtasks.size).toBeGreaterThan(0);
387
+ const result = await scanSessionMessages(mockClient, "session-123");
388
+
389
+ expect(result.epicId).toBe("bd-epic-123");
390
+ expect(result.epicTitle).toBe("Add authentication system");
392
391
  });
393
392
 
394
- it("extracts agent name from swarmmail_init tool calls", async () => {
395
- const mockMessages = [
396
- {
397
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
398
- parts: [
393
+ it("extracts agent name from swarmmail_init tool call", async () => {
394
+ const { scanSessionMessages } = await import("./compaction-hook");
395
+
396
+ const mockClient = {
397
+ session: {
398
+ messages: async () => [
399
399
  {
400
- id: "part-1",
401
- type: "tool",
402
- tool: "swarmmail_init",
403
- state: {
404
- status: "completed",
405
- input: {
406
- project_path: "/Users/joel/project",
407
- task_description: "Working on auth",
400
+ info: { id: "msg-1", sessionID: "session-123" },
401
+ parts: [
402
+ {
403
+ id: "part-1",
404
+ sessionID: "session-123",
405
+ messageID: "msg-1",
406
+ type: "tool" as const,
407
+ callID: "call-1",
408
+ tool: "swarmmail_init",
409
+ state: {
410
+ status: "completed" as const,
411
+ input: {
412
+ project_path: "/test/project",
413
+ task_description: "Working on auth",
414
+ },
415
+ output: JSON.stringify({
416
+ agent_name: "BlueLake",
417
+ project_key: "/test/project",
418
+ }),
419
+ title: "Init Swarm Mail",
420
+ metadata: {},
421
+ time: { start: Date.now(), end: Date.now() },
422
+ },
408
423
  },
409
- output: JSON.stringify({
410
- agent_name: "DarkWind",
411
- project_key: "/Users/joel/project",
412
- }),
413
- title: "Initialized swarm mail",
414
- metadata: {},
415
- time: { start: 1000, end: 2000 },
416
- },
424
+ ],
417
425
  },
418
426
  ],
419
427
  },
420
- ];
421
-
422
- const client = createMockClient(mockMessages);
423
- const { scanSessionMessages } = await import("./compaction-hook");
424
- const result = await scanSessionMessages(client as any, "sess-1");
425
-
426
- expect(result.agentName).toBe("DarkWind");
427
- expect(result.projectPath).toBe("/Users/joel/project");
428
+ } as any;
429
+
430
+ const result = await scanSessionMessages(mockClient, "session-123");
431
+
432
+ expect(result.agentName).toBe("BlueLake");
433
+ expect(result.projectPath).toBe("/test/project");
428
434
  });
429
435
 
430
- it("extracts epic title from hive_create_epic tool calls", async () => {
431
- const mockMessages = [
432
- {
433
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
434
- parts: [
436
+ it("extracts subtask data from swarm_spawn_subtask tool call", async () => {
437
+ const { scanSessionMessages } = await import("./compaction-hook");
438
+
439
+ const mockClient = {
440
+ session: {
441
+ messages: async () => [
435
442
  {
436
- id: "part-1",
437
- type: "tool",
438
- tool: "hive_create_epic",
439
- state: {
440
- status: "completed",
441
- input: {
442
- epic_title: "Add OAuth authentication",
443
- epic_description: "Implement OAuth2 flow",
444
- subtasks: [
445
- { title: "Schema", files: ["src/schema.ts"] },
446
- { title: "Service", files: ["src/service.ts"] },
447
- ],
443
+ info: { id: "msg-1", sessionID: "session-123" },
444
+ parts: [
445
+ {
446
+ id: "part-1",
447
+ sessionID: "session-123",
448
+ messageID: "msg-1",
449
+ type: "tool" as const,
450
+ callID: "call-1",
451
+ tool: "swarm_spawn_subtask",
452
+ state: {
453
+ status: "completed" as const,
454
+ input: {
455
+ bead_id: "bd-task-1",
456
+ epic_id: "bd-epic-123",
457
+ subtask_title: "Implement OAuth service",
458
+ files: ["src/auth/oauth.ts"],
459
+ },
460
+ output: JSON.stringify({
461
+ worker: "RedMountain",
462
+ bead_id: "bd-task-1",
463
+ }),
464
+ title: "Spawn Subtask",
465
+ metadata: {},
466
+ time: { start: Date.now(), end: Date.now() },
467
+ },
448
468
  },
449
- output: JSON.stringify({
450
- epic_id: "bd-epic-oauth-456",
451
- subtask_ids: ["bd-epic-oauth-456.1", "bd-epic-oauth-456.2"],
452
- }),
453
- title: "Created epic",
454
- metadata: {},
455
- time: { start: 1000, end: 2000 },
456
- },
469
+ ],
457
470
  },
458
471
  ],
459
472
  },
460
- ];
461
-
462
- const client = createMockClient(mockMessages);
463
- const { scanSessionMessages } = await import("./compaction-hook");
464
- const result = await scanSessionMessages(client as any, "sess-1");
465
-
466
- expect(result.epicId).toBe("bd-epic-oauth-456");
467
- expect(result.epicTitle).toBe("Add OAuth authentication");
468
- expect(result.subtasks.size).toBe(2);
473
+ } as any;
474
+
475
+ const result = await scanSessionMessages(mockClient, "session-123");
476
+
477
+ expect(result.subtasks.size).toBe(1);
478
+ expect(result.subtasks.get("bd-task-1")).toEqual({
479
+ title: "Implement OAuth service",
480
+ status: "spawned",
481
+ worker: "RedMountain",
482
+ files: ["src/auth/oauth.ts"],
483
+ });
469
484
  });
470
485
 
471
- it("tracks last action with timestamp", async () => {
472
- const mockMessages = [
473
- {
474
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
475
- parts: [
486
+ it("marks subtask as completed from swarm_complete tool call", async () => {
487
+ const { scanSessionMessages } = await import("./compaction-hook");
488
+
489
+ const mockClient = {
490
+ session: {
491
+ messages: async () => [
476
492
  {
477
- id: "part-1",
478
- type: "tool",
479
- tool: "swarm_status",
480
- state: {
481
- status: "completed",
482
- input: { epic_id: "bd-epic-123", project_key: "/path" },
483
- output: JSON.stringify({ status: "in_progress" }),
484
- title: "Checked status",
485
- metadata: {},
486
- time: { start: 5000, end: 6000 },
487
- },
493
+ info: { id: "msg-1", sessionID: "session-123" },
494
+ parts: [
495
+ {
496
+ id: "part-1",
497
+ sessionID: "session-123",
498
+ messageID: "msg-1",
499
+ type: "tool" as const,
500
+ callID: "call-1",
501
+ tool: "swarm_spawn_subtask",
502
+ state: {
503
+ status: "completed" as const,
504
+ input: {
505
+ bead_id: "bd-task-1",
506
+ epic_id: "bd-epic-123",
507
+ subtask_title: "Fix bug",
508
+ files: [],
509
+ },
510
+ output: "{}",
511
+ title: "Spawn",
512
+ metadata: {},
513
+ time: { start: 100, end: 200 },
514
+ },
515
+ },
516
+ {
517
+ id: "part-2",
518
+ sessionID: "session-123",
519
+ messageID: "msg-1",
520
+ type: "tool" as const,
521
+ callID: "call-2",
522
+ tool: "swarm_complete",
523
+ state: {
524
+ status: "completed" as const,
525
+ input: {
526
+ bead_id: "bd-task-1",
527
+ summary: "Fixed the bug",
528
+ },
529
+ output: JSON.stringify({ success: true, closed: true }),
530
+ title: "Complete",
531
+ metadata: {},
532
+ time: { start: 300, end: 400 },
533
+ },
534
+ },
535
+ ],
488
536
  },
489
537
  ],
490
538
  },
491
- ];
492
-
493
- const client = createMockClient(mockMessages);
494
- const { scanSessionMessages } = await import("./compaction-hook");
495
- const result = await scanSessionMessages(client as any, "sess-1");
496
-
497
- expect(result.lastAction).toBeDefined();
498
- expect(result.lastAction?.tool).toBe("swarm_status");
499
- expect(result.lastAction?.timestamp).toBe(6000);
539
+ } as any;
540
+
541
+ const result = await scanSessionMessages(mockClient, "session-123");
542
+
543
+ expect(result.subtasks.get("bd-task-1")?.status).toBe("completed");
500
544
  });
501
545
 
502
- it("ignores non-tool parts", async () => {
503
- const mockMessages = [
504
- {
505
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
506
- parts: [
507
- { id: "part-1", type: "text", text: "Hello" },
508
- { id: "part-2", type: "reasoning", text: "Thinking..." },
509
- ],
510
- },
511
- ];
512
-
513
- const client = createMockClient(mockMessages);
546
+ it("captures last action timestamp", async () => {
514
547
  const { scanSessionMessages } = await import("./compaction-hook");
515
- const result = await scanSessionMessages(client as any, "sess-1");
516
-
517
- expect(result.epicId).toBeUndefined();
518
- expect(result.subtasks.size).toBe(0);
519
- });
520
-
521
- it("ignores pending/running tool states", async () => {
522
- const mockMessages = [
523
- {
524
- info: { id: "msg-1", sessionID: "sess-1", role: "assistant" },
525
- parts: [
526
- {
527
- id: "part-1",
528
- type: "tool",
529
- tool: "swarm_spawn_subtask",
530
- state: {
531
- status: "pending",
532
- input: { epic_id: "bd-epic-123" },
533
- raw: "{}",
534
- },
535
- },
548
+
549
+ const mockClient = {
550
+ session: {
551
+ messages: async () => [
536
552
  {
537
- id: "part-2",
538
- type: "tool",
539
- tool: "swarm_status",
540
- state: {
541
- status: "running",
542
- input: { epic_id: "bd-epic-456" },
543
- time: { start: 1000 },
544
- },
553
+ info: { id: "msg-1", sessionID: "session-123" },
554
+ parts: [
555
+ {
556
+ id: "part-1",
557
+ sessionID: "session-123",
558
+ messageID: "msg-1",
559
+ type: "tool" as const,
560
+ callID: "call-1",
561
+ tool: "swarm_status",
562
+ state: {
563
+ status: "completed" as const,
564
+ input: {
565
+ epic_id: "bd-epic-123",
566
+ project_key: "/test",
567
+ },
568
+ output: "{}",
569
+ title: "Check Status",
570
+ metadata: {},
571
+ time: { start: 1000, end: 2000 },
572
+ },
573
+ },
574
+ ],
545
575
  },
546
576
  ],
547
577
  },
548
- ];
549
-
550
- const client = createMockClient(mockMessages);
551
- const { scanSessionMessages } = await import("./compaction-hook");
552
- const result = await scanSessionMessages(client as any, "sess-1");
553
-
554
- // Should not extract from pending/running states
555
- expect(result.epicId).toBeUndefined();
578
+ } as any;
579
+
580
+ const result = await scanSessionMessages(mockClient, "session-123");
581
+
582
+ expect(result.lastAction).toBeDefined();
583
+ expect(result.lastAction?.tool).toBe("swarm_status");
584
+ expect(result.lastAction?.timestamp).toBe(2000);
556
585
  });
557
586
 
558
- it("handles SDK client errors gracefully", async () => {
559
- const errorClient = {
587
+ it("respects limit parameter", async () => {
588
+ const { scanSessionMessages } = await import("./compaction-hook");
589
+
590
+ let capturedLimit: number | undefined;
591
+ const mockClient = {
560
592
  session: {
561
- messages: async () => ({
562
- data: undefined,
563
- error: { message: "Network error" },
564
- }),
593
+ messages: async ({ limit }: { limit?: number }) => {
594
+ capturedLimit = limit;
595
+ return [];
596
+ },
565
597
  },
566
- };
567
-
568
- const { scanSessionMessages } = await import("./compaction-hook");
569
- const result = await scanSessionMessages(errorClient as any, "sess-1");
570
-
571
- // Should return empty state, not throw
572
- expect(result.subtasks.size).toBe(0);
573
- expect(result.epicId).toBeUndefined();
574
- });
575
-
576
- it("returns empty state when client is undefined", async () => {
577
- const { scanSessionMessages } = await import("./compaction-hook");
578
- const result = await scanSessionMessages(undefined as any, "sess-1");
579
-
580
- expect(result.subtasks.size).toBe(0);
598
+ } as any;
599
+
600
+ await scanSessionMessages(mockClient, "session-123", 50);
601
+
602
+ expect(capturedLimit).toBe(50);
581
603
  });
582
604
  });
583
605
 
@@ -216,6 +216,203 @@ function buildDynamicSwarmState(state: SwarmState): string {
216
216
  return parts.join("\n");
217
217
  }
218
218
 
219
+ // ============================================================================
220
+ // SDK Message Scanning
221
+ // ============================================================================
222
+
223
+ /**
224
+ * Tool part with completed state containing input/output
225
+ */
226
+ interface ToolPart {
227
+ id: string;
228
+ sessionID: string;
229
+ messageID: string;
230
+ type: "tool";
231
+ callID: string;
232
+ tool: string;
233
+ state: ToolState;
234
+ }
235
+
236
+ /**
237
+ * Tool state (completed tools have input/output we need)
238
+ */
239
+ type ToolState = {
240
+ status: "completed";
241
+ input: { [key: string]: unknown };
242
+ output: string;
243
+ title: string;
244
+ metadata: { [key: string]: unknown };
245
+ time: { start: number; end: number };
246
+ } | {
247
+ status: string;
248
+ [key: string]: unknown;
249
+ };
250
+
251
+ /**
252
+ * SDK Client type (minimal interface for scanSessionMessages)
253
+ */
254
+ interface OpencodeClient {
255
+ session: {
256
+ messages: (opts: { sessionID: string; limit?: number }) => Promise<{
257
+ info: { id: string; sessionID: string };
258
+ parts: ToolPart[];
259
+ }[]>;
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Scanned swarm state extracted from session messages
265
+ */
266
+ export interface ScannedSwarmState {
267
+ epicId?: string;
268
+ epicTitle?: string;
269
+ projectPath?: string;
270
+ agentName?: string;
271
+ subtasks: Map<string, { title: string; status: string; worker?: string; files?: string[] }>;
272
+ lastAction?: { tool: string; args: unknown; timestamp: number };
273
+ }
274
+
275
+ /**
276
+ * Scan session messages for swarm state using SDK client
277
+ *
278
+ * Extracts swarm coordination state from actual tool calls:
279
+ * - swarm_spawn_subtask → subtask tracking
280
+ * - swarmmail_init → agent name, project path
281
+ * - hive_create_epic → epic ID and title
282
+ * - swarm_status → epic reference
283
+ * - swarm_complete → subtask completion
284
+ *
285
+ * @param client - OpenCode SDK client (undefined if not available)
286
+ * @param sessionID - Session to scan
287
+ * @param limit - Max messages to fetch (default 100)
288
+ * @returns Extracted swarm state
289
+ */
290
+ export async function scanSessionMessages(
291
+ client: OpencodeClient | undefined,
292
+ sessionID: string,
293
+ limit: number = 100
294
+ ): Promise<ScannedSwarmState> {
295
+ const state: ScannedSwarmState = {
296
+ subtasks: new Map(),
297
+ };
298
+
299
+ if (!client) {
300
+ return state;
301
+ }
302
+
303
+ try {
304
+ const messages = await client.session.messages({ sessionID, limit });
305
+
306
+ for (const message of messages) {
307
+ for (const part of message.parts) {
308
+ if (part.type !== "tool" || part.state.status !== "completed") {
309
+ continue;
310
+ }
311
+
312
+ const { tool, state: toolState } = part;
313
+ const { input, output, time } = toolState as Extract<ToolState, { status: "completed" }>;
314
+
315
+ // Track last action
316
+ state.lastAction = {
317
+ tool,
318
+ args: input,
319
+ timestamp: time.end,
320
+ };
321
+
322
+ // Extract swarm state based on tool type
323
+ switch (tool) {
324
+ case "hive_create_epic": {
325
+ try {
326
+ const parsed = JSON.parse(output);
327
+ if (parsed.epic?.id) {
328
+ state.epicId = parsed.epic.id;
329
+ }
330
+ if (input.epic_title && typeof input.epic_title === "string") {
331
+ state.epicTitle = input.epic_title;
332
+ }
333
+ } catch {
334
+ // Invalid JSON, skip
335
+ }
336
+ break;
337
+ }
338
+
339
+ case "swarmmail_init": {
340
+ try {
341
+ const parsed = JSON.parse(output);
342
+ if (parsed.agent_name) {
343
+ state.agentName = parsed.agent_name;
344
+ }
345
+ if (parsed.project_key) {
346
+ state.projectPath = parsed.project_key;
347
+ }
348
+ } catch {
349
+ // Invalid JSON, skip
350
+ }
351
+ break;
352
+ }
353
+
354
+ case "swarm_spawn_subtask": {
355
+ const beadId = input.bead_id as string | undefined;
356
+ const epicId = input.epic_id as string | undefined;
357
+ const title = input.subtask_title as string | undefined;
358
+ const files = input.files as string[] | undefined;
359
+
360
+ if (beadId && title) {
361
+ let worker: string | undefined;
362
+ try {
363
+ const parsed = JSON.parse(output);
364
+ worker = parsed.worker;
365
+ } catch {
366
+ // No worker in output
367
+ }
368
+
369
+ state.subtasks.set(beadId, {
370
+ title,
371
+ status: "spawned",
372
+ worker,
373
+ files,
374
+ });
375
+
376
+ if (epicId && !state.epicId) {
377
+ state.epicId = epicId;
378
+ }
379
+ }
380
+ break;
381
+ }
382
+
383
+ case "swarm_complete": {
384
+ const beadId = input.bead_id as string | undefined;
385
+ if (beadId && state.subtasks.has(beadId)) {
386
+ const existing = state.subtasks.get(beadId)!;
387
+ state.subtasks.set(beadId, {
388
+ ...existing,
389
+ status: "completed",
390
+ });
391
+ }
392
+ break;
393
+ }
394
+
395
+ case "swarm_status": {
396
+ const epicId = input.epic_id as string | undefined;
397
+ if (epicId && !state.epicId) {
398
+ state.epicId = epicId;
399
+ }
400
+ const projectKey = input.project_key as string | undefined;
401
+ if (projectKey && !state.projectPath) {
402
+ state.projectPath = projectKey;
403
+ }
404
+ break;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ } catch (error) {
410
+ // SDK not available or error fetching messages - return what we have
411
+ }
412
+
413
+ return state;
414
+ }
415
+
219
416
  // ============================================================================
220
417
  // Swarm Detection
221
418
  // ============================================================================
@@ -434,12 +434,6 @@ export const swarm_decompose = tool({
434
434
  "Generate decomposition prompt for breaking task into parallelizable subtasks. Optionally queries CASS for similar past tasks.",
435
435
  args: {
436
436
  task: tool.schema.string().min(1).describe("Task description to decompose"),
437
- max_subtasks: tool.schema
438
- .number()
439
- .int()
440
- .min(1)
441
- .optional()
442
- .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
443
437
  context: tool.schema
444
438
  .string()
445
439
  .optional()
@@ -503,7 +497,6 @@ export const swarm_decompose = tool({
503
497
  : "## Additional Context\n(none provided)";
504
498
 
505
499
  const prompt = DECOMPOSITION_PROMPT.replace("{task}", args.task)
506
- .replace("{max_subtasks}", (args.max_subtasks ?? 5).toString())
507
500
  .replace("{context_section}", contextSection);
508
501
 
509
502
  // Return the prompt and schema info for the caller
@@ -1393,12 +1393,6 @@ export const swarm_plan_prompt = tool({
1393
1393
  .enum(["file-based", "feature-based", "risk-based", "auto"])
1394
1394
  .optional()
1395
1395
  .describe("Decomposition strategy (default: auto-detect)"),
1396
- max_subtasks: tool.schema
1397
- .number()
1398
- .int()
1399
- .min(1)
1400
- .optional()
1401
- .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
1402
1396
  context: tool.schema
1403
1397
  .string()
1404
1398
  .optional()
@@ -1482,8 +1476,7 @@ export const swarm_plan_prompt = tool({
1482
1476
  .replace("{strategy_guidelines}", strategyGuidelines)
1483
1477
  .replace("{context_section}", contextSection)
1484
1478
  .replace("{cass_history}", "") // Empty for now
1485
- .replace("{skills_context}", skillsContext || "")
1486
- .replace("{max_subtasks}", (args.max_subtasks ?? 5).toString());
1479
+ .replace("{skills_context}", skillsContext || "");
1487
1480
 
1488
1481
  return JSON.stringify(
1489
1482
  {