opencode-swarm-plugin 0.50.0 → 0.51.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Session Command - Chainlink-inspired session management
3
+ *
4
+ * Commands:
5
+ * swarm session start [--cell <id>] [--json]
6
+ * swarm session end [--notes "..."] [--json]
7
+ * swarm session status [--json]
8
+ * swarm session history [--limit <n>] [--json]
9
+ *
10
+ * Inspired by: https://github.com/dollspace-gay/chainlink
11
+ * Credit: @dollspace-gay for the session handoff pattern
12
+ */
13
+
14
+ import * as p from "@clack/prompts";
15
+ import {
16
+ getSwarmMailLibSQL,
17
+ createHiveAdapter,
18
+ } from "swarm-mail";
19
+
20
+ // Color utilities (inline, same as swarm.ts)
21
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
22
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
23
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
24
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
25
+
26
+ /**
27
+ * Main session command handler
28
+ */
29
+ export async function session() {
30
+ const args = process.argv.slice(3);
31
+ const subcommand = args[0];
32
+
33
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
34
+ showHelp();
35
+ return;
36
+ }
37
+
38
+ switch (subcommand) {
39
+ case "start":
40
+ await startSession(args.slice(1));
41
+ break;
42
+ case "end":
43
+ await endSession(args.slice(1));
44
+ break;
45
+ case "status":
46
+ await sessionStatus(args.slice(1));
47
+ break;
48
+ case "history":
49
+ await sessionHistory(args.slice(1));
50
+ break;
51
+ default:
52
+ p.log.error(`Unknown session subcommand: ${subcommand}`);
53
+ showHelp();
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Start a new session
60
+ */
61
+ async function startSession(args: string[]) {
62
+ // Parse arguments
63
+ let cellId: string | null = null;
64
+ let jsonOutput = false;
65
+
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i];
68
+
69
+ if (arg === "--cell" && i + 1 < args.length) {
70
+ cellId = args[++i];
71
+ } else if (arg === "--json") {
72
+ jsonOutput = true;
73
+ }
74
+ }
75
+
76
+ const projectPath = process.cwd();
77
+
78
+ try {
79
+ const swarmMail = await getSwarmMailLibSQL(projectPath);
80
+ const db = await swarmMail.getDatabase();
81
+ const adapter = createHiveAdapter(db, projectPath);
82
+
83
+ // Run migrations to ensure schema exists
84
+ await adapter.runMigrations();
85
+
86
+ // Start session
87
+ const session = await adapter.startSession(projectPath, {
88
+ active_cell_id: cellId || undefined,
89
+ });
90
+
91
+ if (jsonOutput) {
92
+ console.log(JSON.stringify(session, null, 2));
93
+ return;
94
+ }
95
+
96
+ // Pretty output
97
+ p.log.success(green(`Session started: ${session.id}`));
98
+
99
+ if (session.active_cell_id) {
100
+ p.log.message(dim(` Active cell: ${session.active_cell_id}`));
101
+ }
102
+
103
+ if (session.previous_handoff_notes) {
104
+ p.log.message(
105
+ `\n${yellow("Previous session notes:")}
106
+ ${dim(session.previous_handoff_notes)}`,
107
+ );
108
+ } else {
109
+ p.log.message(dim("\n (No previous session notes)"));
110
+ }
111
+ } catch (error) {
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ p.log.error(`Failed to start session: ${message}`);
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * End the current session
120
+ */
121
+ async function endSession(args: string[]) {
122
+ // Parse arguments
123
+ let notes: string | null = null;
124
+ let jsonOutput = false;
125
+
126
+ for (let i = 0; i < args.length; i++) {
127
+ const arg = args[i];
128
+
129
+ if (arg === "--notes" && i + 1 < args.length) {
130
+ notes = args[++i];
131
+ } else if (arg === "--json") {
132
+ jsonOutput = true;
133
+ }
134
+ }
135
+
136
+ const projectPath = process.cwd();
137
+
138
+ try {
139
+ const swarmMail = await getSwarmMailLibSQL(projectPath);
140
+ const db = await swarmMail.getDatabase();
141
+ const adapter = createHiveAdapter(db, projectPath);
142
+
143
+ // Run migrations
144
+ await adapter.runMigrations();
145
+
146
+ // Get current session
147
+ const currentSession = await adapter.getCurrentSession(projectPath);
148
+
149
+ if (!currentSession) {
150
+ p.log.error("No active session to end");
151
+ process.exit(1);
152
+ }
153
+
154
+ // End session
155
+ const endedSession = await adapter.endSession(
156
+ projectPath,
157
+ currentSession.id,
158
+ {
159
+ handoff_notes: notes || undefined,
160
+ },
161
+ );
162
+
163
+ if (jsonOutput) {
164
+ console.log(JSON.stringify(endedSession, null, 2));
165
+ return;
166
+ }
167
+
168
+ // Pretty output
169
+ p.log.success(green(`Session ended: ${endedSession.id}`));
170
+
171
+ if (endedSession.handoff_notes) {
172
+ p.log.message(
173
+ dim(` Handoff notes saved for next session:
174
+ ${endedSession.handoff_notes}`),
175
+ );
176
+ }
177
+
178
+ const duration = endedSession.ended_at! - endedSession.started_at;
179
+ const durationStr = formatDuration(duration);
180
+ p.log.message(dim(` Duration: ${durationStr}`));
181
+ } catch (error) {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ p.log.error(`Failed to end session: ${message}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Show current session status
190
+ */
191
+ async function sessionStatus(args: string[]) {
192
+ const jsonOutput = args.includes("--json");
193
+ const projectPath = process.cwd();
194
+
195
+ try {
196
+ const swarmMail = await getSwarmMailLibSQL(projectPath);
197
+ const db = await swarmMail.getDatabase();
198
+ const adapter = createHiveAdapter(db, projectPath);
199
+
200
+ // Run migrations
201
+ await adapter.runMigrations();
202
+
203
+ // Get current session
204
+ const currentSession = await adapter.getCurrentSession(projectPath);
205
+
206
+ if (!currentSession) {
207
+ if (jsonOutput) {
208
+ console.log(JSON.stringify({ active: false }, null, 2));
209
+ } else {
210
+ p.log.message("No active session");
211
+ }
212
+ return;
213
+ }
214
+
215
+ if (jsonOutput) {
216
+ console.log(
217
+ JSON.stringify({ active: true, session: currentSession }, null, 2),
218
+ );
219
+ return;
220
+ }
221
+
222
+ // Pretty output
223
+ p.log.message(green("Active session:"));
224
+ p.log.message(dim(` ID: ${currentSession.id}`));
225
+ p.log.message(
226
+ dim(` Started: ${new Date(currentSession.started_at).toLocaleString()}`),
227
+ );
228
+
229
+ if (currentSession.active_cell_id) {
230
+ p.log.message(dim(` Active cell: ${currentSession.active_cell_id}`));
231
+ }
232
+
233
+ const elapsed = Date.now() - currentSession.started_at;
234
+ p.log.message(dim(` Elapsed: ${formatDuration(elapsed)}`));
235
+ } catch (error) {
236
+ const message = error instanceof Error ? error.message : String(error);
237
+ p.log.error(`Failed to get session status: ${message}`);
238
+ process.exit(1);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Show session history
244
+ */
245
+ async function sessionHistory(args: string[]) {
246
+ // Parse arguments
247
+ let limit = 10;
248
+ let jsonOutput = false;
249
+
250
+ for (let i = 0; i < args.length; i++) {
251
+ const arg = args[i];
252
+
253
+ if (arg === "--limit" && i + 1 < args.length) {
254
+ limit = parseInt(args[++i], 10);
255
+ if (isNaN(limit) || limit <= 0) {
256
+ p.log.error(`Invalid limit: ${args[i]}`);
257
+ process.exit(1);
258
+ }
259
+ } else if (arg === "--json") {
260
+ jsonOutput = true;
261
+ }
262
+ }
263
+
264
+ const projectPath = process.cwd();
265
+
266
+ try {
267
+ const swarmMail = await getSwarmMailLibSQL(projectPath);
268
+ const db = await swarmMail.getDatabase();
269
+ const adapter = createHiveAdapter(db, projectPath);
270
+
271
+ // Run migrations
272
+ await adapter.runMigrations();
273
+
274
+ // Get history
275
+ const sessions = await adapter.getSessionHistory(projectPath, { limit });
276
+
277
+ if (jsonOutput) {
278
+ console.log(JSON.stringify(sessions, null, 2));
279
+ return;
280
+ }
281
+
282
+ // Pretty output
283
+ if (sessions.length === 0) {
284
+ p.log.message("No session history");
285
+ return;
286
+ }
287
+
288
+ p.log.message(cyan(`Recent sessions (${sessions.length}):\n`));
289
+
290
+ for (const session of sessions) {
291
+ const startedAt = new Date(session.started_at).toLocaleString();
292
+ const status = session.ended_at ? "ended" : green("active");
293
+
294
+ p.log.message(` ${dim(`#${session.id}`)} - ${status}`);
295
+ p.log.message(dim(` Started: ${startedAt}`));
296
+
297
+ if (session.ended_at) {
298
+ const duration = session.ended_at - session.started_at;
299
+ p.log.message(dim(` Duration: ${formatDuration(duration)}`));
300
+ }
301
+
302
+ if (session.active_cell_id) {
303
+ p.log.message(dim(` Cell: ${session.active_cell_id}`));
304
+ }
305
+
306
+ if (session.handoff_notes) {
307
+ p.log.message(dim(` Notes: ${session.handoff_notes.slice(0, 60)}...`));
308
+ }
309
+
310
+ p.log.message(""); // Blank line
311
+ }
312
+ } catch (error) {
313
+ const message = error instanceof Error ? error.message : String(error);
314
+ p.log.error(`Failed to get session history: ${message}`);
315
+ process.exit(1);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Format duration in human-readable format
321
+ */
322
+ function formatDuration(ms: number): string {
323
+ const seconds = Math.floor(ms / 1000);
324
+ const minutes = Math.floor(seconds / 60);
325
+ const hours = Math.floor(minutes / 60);
326
+ const days = Math.floor(hours / 24);
327
+
328
+ if (days > 0) {
329
+ return `${days}d ${hours % 24}h`;
330
+ }
331
+ if (hours > 0) {
332
+ return `${hours}h ${minutes % 60}m`;
333
+ }
334
+ if (minutes > 0) {
335
+ return `${minutes}m ${seconds % 60}s`;
336
+ }
337
+ return `${seconds}s`;
338
+ }
339
+
340
+ /**
341
+ * Show help
342
+ */
343
+ function showHelp() {
344
+ console.log(`
345
+ ${cyan("swarm session")} - Manage work sessions with handoff notes
346
+
347
+ ${cyan("Commands:")}
348
+ swarm session start [--cell <id>] Start a new session
349
+ swarm session end [--notes "..."] End current session with optional handoff notes
350
+ swarm session status Show current session info
351
+ swarm session history [--limit n] Show session history (default: 10)
352
+
353
+ ${cyan("Options:")}
354
+ --json Output as JSON
355
+ --cell <id> Set active cell when starting session
356
+ --notes "..." Save handoff notes for next session
357
+ --limit <n> Limit history results
358
+
359
+ ${cyan("Examples:")}
360
+ ${dim("# Start session")}
361
+ swarm session start
362
+
363
+ ${dim("# Start session with active cell")}
364
+ swarm session start --cell opencode-swarm-monorepo-lf2p4u-mk2uv4j7u3o
365
+
366
+ ${dim("# End session with handoff notes")}
367
+ swarm session end --notes "Completed auth flow. Next: add error handling"
368
+
369
+ ${dim("# Check current session")}
370
+ swarm session status
371
+
372
+ ${dim("# View recent sessions")}
373
+ swarm session history --limit 5
374
+
375
+ ${dim("Inspired by Chainlink: https://github.com/dollspace-gay/chainlink")}
376
+ `);
377
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @fileoverview Tests for tree command
3
+ *
4
+ * Integration tests for CLI command that renders cell hierarchies.
5
+ */
6
+
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { createInMemorySwarmMailLibSQL, createHiveAdapter } from "swarm-mail";
9
+ import type { SwarmMailAdapter } from "swarm-mail";
10
+
11
+ describe("tree command integration", () => {
12
+ let swarmMail: SwarmMailAdapter;
13
+ const projectPath = "/tmp/test-tree-project";
14
+
15
+ beforeAll(async () => {
16
+ swarmMail = await createInMemorySwarmMailLibSQL("test-tree");
17
+ const db = await swarmMail.getDatabase();
18
+ const adapter = createHiveAdapter(db, projectPath);
19
+ await adapter.runMigrations();
20
+
21
+ // Create test data: epic with children
22
+ const epic = await adapter.createCell(projectPath, {
23
+ title: "Test Epic",
24
+ type: "epic",
25
+ priority: 0,
26
+ });
27
+
28
+ await adapter.createCell(projectPath, {
29
+ title: "Task 1",
30
+ type: "task",
31
+ priority: 1,
32
+ parent_id: epic.id,
33
+ });
34
+
35
+ await adapter.createCell(projectPath, {
36
+ title: "Task 2",
37
+ type: "task",
38
+ priority: 2,
39
+ parent_id: epic.id,
40
+ });
41
+
42
+ // Create standalone task
43
+ await adapter.createCell(projectPath, {
44
+ title: "Standalone Task",
45
+ type: "task",
46
+ priority: 1,
47
+ });
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await swarmMail.close();
52
+ });
53
+
54
+ test("queries cells and builds tree structure", async () => {
55
+ const db = await swarmMail.getDatabase();
56
+ const adapter = createHiveAdapter(db, projectPath);
57
+
58
+ const cells = await adapter.queryCells(projectPath, {
59
+ limit: 100,
60
+ });
61
+
62
+ expect(cells.length).toBeGreaterThan(0);
63
+
64
+ // Verify we have epic and children
65
+ const epic = cells.find((c) => c.type === "epic");
66
+ expect(epic).toBeDefined();
67
+
68
+ const children = cells.filter((c) => c.parent_id === epic?.id);
69
+ expect(children.length).toBe(2);
70
+ });
71
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @fileoverview Tree command for visualizing cell hierarchies
3
+ *
4
+ * Inspired by Chainlink's tree visualization.
5
+ * Credit: https://github.com/dollspace-gay/chainlink
6
+ *
7
+ * Usage:
8
+ * swarm tree - Show all cells as tree
9
+ * swarm tree --status open - Filter by status
10
+ * swarm tree --epic <id> - Show specific epic subtree
11
+ * swarm tree --json - JSON output
12
+ */
13
+
14
+ import * as p from "@clack/prompts";
15
+ import { getSwarmMailLibSQL, createHiveAdapter } from "swarm-mail";
16
+ import type { Cell } from "swarm-mail";
17
+ import {
18
+ buildTreeStructure,
19
+ renderTree,
20
+ } from "../../src/utils/tree-renderer.js";
21
+
22
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
23
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
24
+
25
+ export interface TreeOptions {
26
+ status?: string;
27
+ epic?: string;
28
+ json?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Parse tree command arguments
33
+ */
34
+ export function parseTreeArgs(args: string[]): TreeOptions {
35
+ const options: TreeOptions = {};
36
+
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i];
39
+
40
+ if (arg === "--status" && i + 1 < args.length) {
41
+ options.status = args[++i];
42
+ } else if (arg === "--epic" && i + 1 < args.length) {
43
+ options.epic = args[++i];
44
+ } else if (arg === "--json") {
45
+ options.json = true;
46
+ }
47
+ }
48
+
49
+ return options;
50
+ }
51
+
52
+ /**
53
+ * Execute tree command
54
+ */
55
+ export async function tree(args: string[] = []) {
56
+ const options = parseTreeArgs(args);
57
+
58
+ const projectPath = process.cwd();
59
+
60
+ try {
61
+ const swarmMail = await getSwarmMailLibSQL(projectPath);
62
+ const db = await swarmMail.getDatabase();
63
+ const adapter = createHiveAdapter(db, projectPath);
64
+
65
+ // Run migrations to ensure schema exists
66
+ await adapter.runMigrations();
67
+
68
+ // Query cells with filters
69
+ let cells: Cell[];
70
+
71
+ if (options.epic) {
72
+ // Get epic and its descendants
73
+ const epic = await adapter.getCell(projectPath, options.epic);
74
+ if (!epic) {
75
+ p.log.error(`Epic not found: ${options.epic}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ // Get all cells that are children of this epic
80
+ const allCells = await adapter.queryCells(projectPath, {
81
+ limit: 1000,
82
+ });
83
+
84
+ // Filter to epic and its descendants
85
+ cells = [epic];
86
+ const childIds = new Set([epic.id]);
87
+
88
+ // Iteratively find all descendants
89
+ let foundNew = true;
90
+ while (foundNew) {
91
+ foundNew = false;
92
+ for (const cell of allCells) {
93
+ if (
94
+ cell.parent_id &&
95
+ childIds.has(cell.parent_id) &&
96
+ !childIds.has(cell.id)
97
+ ) {
98
+ cells.push(cell);
99
+ childIds.add(cell.id);
100
+ foundNew = true;
101
+ }
102
+ }
103
+ }
104
+ } else {
105
+ // Get all cells
106
+ cells = await adapter.queryCells(projectPath, {
107
+ status: options.status as any,
108
+ limit: 1000,
109
+ });
110
+ }
111
+
112
+ if (cells.length === 0) {
113
+ p.log.message(dim("No cells found"));
114
+ return;
115
+ }
116
+
117
+ // Output
118
+ if (options.json) {
119
+ const tree = buildTreeStructure(cells);
120
+ console.log(JSON.stringify(tree, null, 2));
121
+ } else {
122
+ const tree = buildTreeStructure(cells);
123
+ const output = renderTree(tree);
124
+ console.log(output);
125
+ }
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ p.log.error(`Failed to render tree: ${message}`);
129
+ process.exit(1);
130
+ }
131
+ }
package/bin/swarm.ts CHANGED
@@ -82,6 +82,8 @@ import {
82
82
  exportToCSV,
83
83
  exportToJSON,
84
84
  } from "../src/export-tools.js";
85
+ import { tree } from "./commands/tree.js";
86
+ import { session } from "./commands/session.js";
85
87
  import {
86
88
  querySwarmHistory,
87
89
  formatSwarmHistory,
@@ -3283,6 +3285,11 @@ async function exportEvents() {
3283
3285
  }
3284
3286
  }
3285
3287
 
3288
+ async function treeCommand() {
3289
+ const args = process.argv.slice(3);
3290
+ await tree(args);
3291
+ }
3292
+
3286
3293
  async function help() {
3287
3294
  console.log(yellow(BANNER));
3288
3295
  console.log(dim(" " + TAGLINE + " v" + VERSION));
@@ -3313,6 +3320,8 @@ ${cyan("Commands:")}
3313
3320
  swarm dashboard Live terminal UI with worker status (--epic, --refresh)
3314
3321
  swarm replay Event replay with timing (--speed, --type, --agent, --since, --until)
3315
3322
  swarm export Export events (--format otlp/csv/json, --epic, --output)
3323
+ swarm tree Visualize cell hierarchy as ASCII tree (--status, --epic, --json)
3324
+ swarm session Manage work sessions with handoff notes (start, end, status, history)
3316
3325
  swarm update Update to latest version
3317
3326
  swarm version Show version and banner
3318
3327
  swarm tool Execute a tool (for plugin wrapper)
@@ -3394,6 +3403,16 @@ ${cyan("Observability Commands:")}
3394
3403
  swarm export --format csv Export as CSV
3395
3404
  swarm export --epic <id> Export specific epic only
3396
3405
  swarm export --output <file> Write to file instead of stdout
3406
+ swarm tree Show all cells as tree
3407
+ swarm tree --status open Show only open cells
3408
+ swarm tree --epic <id> Show specific epic subtree
3409
+ swarm tree --json Output as JSON
3410
+
3411
+ ${cyan("Session Management (Chainlink-inspired):")}
3412
+ swarm session start [--cell <id>] Start new session (shows previous handoff notes)
3413
+ swarm session end [--notes "..."] End session with handoff notes for next session
3414
+ swarm session status Show current session info
3415
+ swarm session history [--limit n] Show session history (default: 10)
3397
3416
 
3398
3417
  ${cyan("Usage in OpenCode:")}
3399
3418
  /swarm "Add user authentication with OAuth"
@@ -6220,6 +6239,12 @@ switch (command) {
6220
6239
  case "export":
6221
6240
  await exportEvents();
6222
6241
  break;
6242
+ case "tree":
6243
+ await treeCommand();
6244
+ break;
6245
+ case "session":
6246
+ await session();
6247
+ break;
6223
6248
  case "version":
6224
6249
  case "--version":
6225
6250
  case "-v":