claude-code-swarm 0.3.12 → 0.3.17

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/src/bootstrap.mjs CHANGED
@@ -431,12 +431,37 @@ export async function bootstrap(pluginDirOverride, sessionId) {
431
431
 
432
432
  let minimemStatus = "disabled";
433
433
  if (config.minimem?.enabled) {
434
- minimemStatus = "enabled";
434
+ // Check if minimem CLI is available and memory directory exists
435
+ try {
436
+ const { execFileSync } = await import("node:child_process");
437
+ const { existsSync } = await import("node:fs");
438
+ execFileSync("which", ["minimem"], { stdio: "pipe" });
439
+ const dir = config.minimem?.dir || ".swarm/minimem";
440
+ if (existsSync(dir)) {
441
+ minimemStatus = "ready";
442
+ } else {
443
+ // Create the directory and initialize so MCP server can start
444
+ const { mkdirSync } = await import("node:fs");
445
+ mkdirSync(dir + "/memory", { recursive: true });
446
+ try {
447
+ execFileSync("minimem", ["init", dir], { stdio: "pipe", timeout: 10_000 });
448
+ } catch { /* init may fail if already initialized */ }
449
+ minimemStatus = "ready";
450
+ }
451
+ } catch {
452
+ minimemStatus = "enabled"; // CLI not found — MCP server won't start
453
+ }
435
454
  }
436
455
 
437
456
  let skilltreeStatus = "disabled";
438
457
  if (config.skilltree?.enabled) {
439
- skilltreeStatus = "enabled";
458
+ try {
459
+ const { execFileSync } = await import("node:child_process");
460
+ execFileSync("which", ["skill-tree"], { stdio: "pipe" });
461
+ skilltreeStatus = "ready";
462
+ } catch {
463
+ skilltreeStatus = "enabled";
464
+ }
440
465
  }
441
466
 
442
467
  return {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * content-provider.mjs — Trajectory content provider for claude-code-swarm
3
+ *
4
+ * Provides session transcript content for on-demand trajectory/content requests
5
+ * from the hub. Reads from sessionlog's session state to find the transcript,
6
+ * then serves the raw Claude Code JSONL.
7
+ *
8
+ * Two content sources:
9
+ * 1. Live session: reads directly from state.transcriptPath (active JSONL file)
10
+ * 2. Committed checkpoint: reads from sessionlog's checkpoint store (full.jsonl)
11
+ *
12
+ * Returns { metadata, transcript, prompts, context } matching the
13
+ * SessionContentProvider type from @multi-agent-protocol/sdk.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import { SESSIONLOG_DIR } from "./paths.mjs";
19
+ import { resolvePackage } from "./swarmkit-resolver.mjs";
20
+ import { createLogger } from "./log.mjs";
21
+
22
+ const log = createLogger("content-provider");
23
+
24
+ /**
25
+ * Create a content provider function for the sidecar.
26
+ * The provider receives a checkpointId and returns transcript content.
27
+ *
28
+ * For live sessions, checkpointId may be the session ID or a checkpoint ID.
29
+ * We search sessionlog state to find the transcript path.
30
+ *
31
+ * @returns {Function} SessionContentProvider-compatible async function
32
+ */
33
+ export function createContentProvider() {
34
+ return async function provideContent(checkpointId) {
35
+ try {
36
+ // 1. Try to find a live session with this checkpoint or session ID
37
+ const liveContent = await readLiveSessionContent(checkpointId);
38
+ if (liveContent) return liveContent;
39
+
40
+ // 2. Try to read from committed checkpoint store
41
+ const committedContent = await readCommittedContent(checkpointId);
42
+ if (committedContent) return committedContent;
43
+
44
+ log.warn("content not found for checkpoint", { checkpointId });
45
+ return null;
46
+ } catch (err) {
47
+ log.warn("content provider error", { checkpointId, error: err.message });
48
+ return null;
49
+ }
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Read transcript content from a live (non-ended) sessionlog session.
55
+ * Searches all session state files for one that matches the checkpoint ID
56
+ * or has a matching session ID, then reads the transcript from disk.
57
+ */
58
+ async function readLiveSessionContent(checkpointId) {
59
+ if (!fs.existsSync(SESSIONLOG_DIR)) return null;
60
+
61
+ let files;
62
+ try {
63
+ files = fs.readdirSync(SESSIONLOG_DIR).filter(f => f.endsWith(".json"));
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ for (const f of files) {
69
+ try {
70
+ const statePath = path.join(SESSIONLOG_DIR, f);
71
+ const state = JSON.parse(fs.readFileSync(statePath, "utf-8"));
72
+
73
+ // Match by session ID, checkpoint ID, or checkpoint in turnCheckpointIDs
74
+ const isMatch =
75
+ state.sessionID === checkpointId ||
76
+ state.lastCheckpointID === checkpointId ||
77
+ (state.turnCheckpointIDs || []).includes(checkpointId);
78
+
79
+ if (!isMatch) continue;
80
+
81
+ // Read the transcript from the path stored in session state
82
+ const transcriptPath = state.transcriptPath;
83
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
84
+ log.warn("transcript path not found", { sessionID: state.sessionID, transcriptPath });
85
+ continue;
86
+ }
87
+
88
+ const transcript = fs.readFileSync(transcriptPath, "utf-8");
89
+
90
+ // Extract prompts from transcript (user messages)
91
+ const prompts = extractPrompts(transcript);
92
+
93
+ return {
94
+ metadata: {
95
+ sessionID: state.sessionID,
96
+ phase: state.phase,
97
+ stepCount: state.stepCount || 0,
98
+ filesTouched: state.filesTouched || [],
99
+ tokenUsage: state.tokenUsage || {},
100
+ startedAt: state.startedAt,
101
+ endedAt: state.endedAt,
102
+ source: "live",
103
+ },
104
+ transcript,
105
+ prompts,
106
+ context: `Session ${state.sessionID} (${state.phase})`,
107
+ };
108
+ } catch {
109
+ // Skip malformed files
110
+ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Read transcript content from sessionlog's committed checkpoint store.
118
+ * Uses sessionlog's library API via resolvePackage.
119
+ */
120
+ async function readCommittedContent(checkpointId) {
121
+ try {
122
+ const sessionlogMod = await resolvePackage("sessionlog");
123
+ if (!sessionlogMod?.createCheckpointStore) return null;
124
+
125
+ const store = sessionlogMod.createCheckpointStore();
126
+ if (!store?.readSessionContent) return null;
127
+
128
+ // Try reading committed content (session index 0)
129
+ const content = await store.readSessionContent(checkpointId, 0);
130
+ if (!content) return null;
131
+
132
+ return {
133
+ metadata: {
134
+ ...content.metadata,
135
+ source: "committed",
136
+ },
137
+ transcript: content.transcript,
138
+ prompts: content.prompts,
139
+ context: content.context,
140
+ };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Extract user prompts from a Claude Code JSONL transcript.
148
+ */
149
+ function extractPrompts(transcript) {
150
+ const prompts = [];
151
+ for (const line of transcript.split("\n")) {
152
+ if (!line.trim()) continue;
153
+ try {
154
+ const entry = JSON.parse(line);
155
+ if (entry.type === "user") {
156
+ const msg = entry.message;
157
+ if (typeof msg === "string") {
158
+ prompts.push(msg);
159
+ } else if (msg?.content) {
160
+ if (typeof msg.content === "string") {
161
+ prompts.push(msg.content);
162
+ } else if (Array.isArray(msg.content)) {
163
+ const text = msg.content
164
+ .filter(b => b.type === "text" && b.text)
165
+ .map(b => b.text)
166
+ .join("\n");
167
+ if (text) prompts.push(text);
168
+ }
169
+ }
170
+ }
171
+ } catch {
172
+ // Skip malformed lines
173
+ }
174
+ }
175
+ return prompts.join("\n---\n");
176
+ }
@@ -24,7 +24,7 @@ const log = createLogger("map");
24
24
  * authRequired challenge with the server's preferred method + this credential.
25
25
  * When absent, uses the standard SDK connect() for open mode servers.
26
26
  */
27
- export async function connectToMAP({ server, scope, systemId, onMessage, credential }) {
27
+ export async function connectToMAP({ server, scope, systemId, onMessage, credential, projectContext }) {
28
28
  try {
29
29
  const mapSdk = await resolvePackage("@multi-agent-protocol/sdk");
30
30
  if (!mapSdk) throw new Error("@multi-agent-protocol/sdk not available");
@@ -38,12 +38,16 @@ export async function connectToMAP({ server, scope, systemId, onMessage, credent
38
38
  role: "sidecar",
39
39
  scopes: [scope],
40
40
  capabilities: {
41
- trajectory: { canReport: true },
41
+ trajectory: { canReport: true, canServeContent: true },
42
42
  tasks: { canCreate: true, canAssign: true, canUpdate: true, canList: true },
43
+ ...(projectContext?.task_graph ? {
44
+ opentasks: { canQuery: true, canLink: true, canAnnotate: true, canTask: true },
45
+ } : {}),
43
46
  },
44
47
  metadata: {
45
48
  systemId,
46
49
  type: "claude-code-swarm-sidecar",
50
+ ...(projectContext || {}),
47
51
  },
48
52
  reconnection: {
49
53
  enabled: true,
@@ -443,3 +443,33 @@ export async function handleNativeTaskUpdatedEvent(config, hookData, sessionId)
443
443
  }, sessionId);
444
444
  }
445
445
  }
446
+
447
+ // ── minimem/skill-tree sync → MAP ──────────────────────────────────────────────
448
+ //
449
+ // When agents use minimem MCP tools that write data (append, upsert),
450
+ // emit x-openhive/memory.sync so OpenHive can update its content cache.
451
+ // This is a best-effort notification — the source of truth is the filesystem.
452
+
453
+ /** Tools that indicate a memory write (vs read-only search) */
454
+ const MINIMEM_WRITE_TOOLS = new Set([
455
+ "minimem__memory_append",
456
+ "minimem__memory_upsert",
457
+ ]);
458
+
459
+ /**
460
+ * Build bridge command for minimem MCP tool use.
461
+ * Only emits for write operations (append/upsert), not reads.
462
+ */
463
+ export function buildMinimemBridgeCommand(hookData) {
464
+ const toolName = hookData.tool_name || "";
465
+ // Only emit for write operations
466
+ if (!MINIMEM_WRITE_TOOLS.has(toolName) && !toolName.includes("append") && !toolName.includes("upsert")) {
467
+ return null;
468
+ }
469
+
470
+ return {
471
+ action: "bridge-memory-sync",
472
+ agentId: hookData.session_id || "minimem",
473
+ timestamp: new Date().toISOString(),
474
+ };
475
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * memory-watcher.mjs — Watches the minimem memory directory for file changes
3
+ * and sends bridge-memory-sync commands to notify OpenHive via MAP.
4
+ *
5
+ * This bridges the gap between filesystem writes (Write tool, manual edits)
6
+ * and MAP sync notifications. minimem's MCP tools are read-only, so this
7
+ * watcher is the only way to detect when an agent writes to memory.
8
+ *
9
+ * Runs inside the MAP sidecar process (persistent for the session).
10
+ */
11
+
12
+ import chokidar from "chokidar";
13
+ import { existsSync } from "fs";
14
+ import { createLogger } from "./log.mjs";
15
+
16
+ const log = createLogger("memory-watcher");
17
+
18
+ const DEBOUNCE_MS = 2000;
19
+
20
+ /**
21
+ * Start watching a minimem directory for file changes.
22
+ * When changes are detected (debounced), calls the provided callback.
23
+ *
24
+ * @param {string} memoryDir - Path to the minimem directory (e.g., ".swarm/minimem")
25
+ * @param {(event: { type: string; path: string }) => void} onSync - Called when sync should be emitted
26
+ * @returns {{ close: () => void } | null} Watcher handle, or null if directory doesn't exist
27
+ */
28
+ export function startMemoryWatcher(memoryDir, onSync) {
29
+ if (!memoryDir || !existsSync(memoryDir)) {
30
+ log.debug("memory watcher skipped — directory not found", { dir: memoryDir });
31
+ return null;
32
+ }
33
+
34
+ let debounceTimer = null;
35
+
36
+ const watcher = chokidar.watch(memoryDir, {
37
+ ignoreInitial: true,
38
+ ignored: [/node_modules/, /\.git/, /index\.db/, /\.cache/, /\.minimem/],
39
+ depth: 3,
40
+ });
41
+
42
+ function debouncedSync(eventType, filePath) {
43
+ // Only react to .md file changes
44
+ if (!filePath.endsWith(".md")) return;
45
+
46
+ if (debounceTimer) clearTimeout(debounceTimer);
47
+ debounceTimer = setTimeout(() => {
48
+ debounceTimer = null;
49
+ log.debug("memory change detected", { event: eventType, path: filePath });
50
+ onSync({ type: eventType, path: filePath });
51
+ }, DEBOUNCE_MS);
52
+ }
53
+
54
+ watcher.on("add", (p) => debouncedSync("add", p));
55
+ watcher.on("change", (p) => debouncedSync("change", p));
56
+ watcher.on("unlink", (p) => debouncedSync("unlink", p));
57
+
58
+ watcher.on("ready", () => {
59
+ log.info("memory watcher started", { dir: memoryDir });
60
+ });
61
+
62
+ watcher.on("error", (err) => {
63
+ log.warn("memory watcher error", { error: err.message });
64
+ });
65
+
66
+ return {
67
+ close() {
68
+ if (debounceTimer) clearTimeout(debounceTimer);
69
+ watcher.close();
70
+ log.debug("memory watcher stopped");
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * opentasks-connector.mjs — MAP connector registration for opentasks
3
+ *
4
+ * Extracted from map-sidecar.mjs for testability.
5
+ * Registers notification handlers on a MAP connection so that when the hub
6
+ * (or another agent) sends opentasks/*.request notifications, the connector
7
+ * queries the local daemon and sends back opentasks/*.response.
8
+ */
9
+
10
+ import { createLogger } from "./log.mjs";
11
+
12
+ const log = createLogger("opentasks-connector");
13
+
14
+ /**
15
+ * Register opentasks notification handlers on a MAP connection.
16
+ *
17
+ * @param {object} conn - MAP connection with onNotification/sendNotification
18
+ * @param {object} options
19
+ * @param {string} options.scope - MAP scope (e.g. "swarm:gsd")
20
+ * @param {() => void} [options.onActivity] - Called on each notification (for inactivity timer reset)
21
+ * @param {() => Promise<object>} [options.importOpentasks] - Override for dynamic import("opentasks")
22
+ * @param {() => Promise<object>} [options.importOpentasksClient] - Override for dynamic import of opentasks-client
23
+ */
24
+ export async function registerOpenTasksHandler(conn, options = {}) {
25
+ if (!conn || typeof conn.onNotification !== "function") return;
26
+
27
+ const {
28
+ scope = "swarm:default",
29
+ onActivity,
30
+ importOpentasks,
31
+ importOpentasksClient,
32
+ } = options;
33
+
34
+ try {
35
+ const opentasks = importOpentasks
36
+ ? await importOpentasks()
37
+ : await import("opentasks");
38
+
39
+ if (!opentasks?.createMAPConnector || !opentasks?.createClient) {
40
+ log.debug("opentasks MAP connector not available (missing exports)");
41
+ return;
42
+ }
43
+
44
+ const { createMAPConnector, createClient, MAP_CONNECTOR_METHODS } = opentasks;
45
+
46
+ const opentasksClient = importOpentasksClient
47
+ ? await importOpentasksClient()
48
+ : await import("./opentasks-client.mjs");
49
+
50
+ const { findSocketPath } = opentasksClient;
51
+ const socketPath = findSocketPath();
52
+ const client = createClient({ socketPath, autoConnect: true });
53
+
54
+ const connector = createMAPConnector({
55
+ client,
56
+ send: (method, params) => {
57
+ try {
58
+ conn.sendNotification(method, params);
59
+ } catch {
60
+ log.debug("failed to send opentasks response", { method });
61
+ }
62
+ },
63
+ agentId: `${scope}-sidecar`,
64
+ });
65
+
66
+ // Register handlers for all 4 request methods
67
+ const requestMethods = [
68
+ MAP_CONNECTOR_METHODS.QUERY_REQUEST,
69
+ MAP_CONNECTOR_METHODS.LINK_REQUEST,
70
+ MAP_CONNECTOR_METHODS.ANNOTATE_REQUEST,
71
+ MAP_CONNECTOR_METHODS.TASK_REQUEST,
72
+ ];
73
+
74
+ for (const method of requestMethods) {
75
+ conn.onNotification(method, async (params) => {
76
+ log.debug("opentasks request received", { method, requestId: params?.request_id });
77
+ if (onActivity) onActivity();
78
+ connector.handleNotification(method, params || {});
79
+ });
80
+ }
81
+
82
+ log.info("opentasks connector registered", { methods: requestMethods.length });
83
+ } catch (err) {
84
+ log.debug("opentasks connector not available", { error: err.message });
85
+ }
86
+ }
@@ -16,6 +16,20 @@ import { sendToSidecar, ensureSidecar } from "./sidecar-client.mjs";
16
16
  import { fireAndForgetTrajectory } from "./map-connection.mjs";
17
17
  import { resolvePackage } from "./swarmkit-resolver.mjs";
18
18
 
19
+ /**
20
+ * Get the current git branch name. Returns null if not in a git repo.
21
+ */
22
+ function getGitBranch() {
23
+ try {
24
+ return execSync("git rev-parse --abbrev-ref HEAD", {
25
+ encoding: "utf-8",
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ }).trim() || null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
19
33
  /**
20
34
  * Check if sessionlog is installed and active.
21
35
  * Returns 'active', 'installed but not enabled', or 'not installed'.
@@ -151,6 +165,7 @@ export function buildTrajectoryCheckpoint(state, syncLevel, config) {
151
165
  id,
152
166
  session_id: state.sessionID,
153
167
  agent: `${teamName}-sidecar`,
168
+ branch: getGitBranch(),
154
169
  files_touched: [],
155
170
  checkpoints_count: 0,
156
171
  };
@@ -161,6 +176,10 @@ export function buildTrajectoryCheckpoint(state, syncLevel, config) {
161
176
  turnId: state.turnID,
162
177
  startedAt: state.startedAt,
163
178
  label: `Turn ${state.turnID || "?"} (step ${state.stepCount || 0}, ${state.phase || "unknown"})`,
179
+ // Project context for display
180
+ project: path.basename(process.cwd()),
181
+ firstPrompt: state.firstPrompt ? state.firstPrompt.slice(0, 200) : undefined,
182
+ template: config.template || undefined,
164
183
  };
165
184
  if (state.endedAt) metadata.endedAt = state.endedAt;
166
185
 
@@ -94,6 +94,8 @@ export function createSocketServer(socketPath, onCommand) {
94
94
  export function createCommandHandler(connection, scope, registeredAgents, opts = {}) {
95
95
  // Use a getter pattern so the connection ref can be updated
96
96
  let conn = connection;
97
+ let _trajectoryResourceId = null; // Cached resource_id from server response
98
+ let _memoryResourceId = null; // Cached resource_id for memory sync
97
99
  const { inboxInstance, meshPeer, transportMode = "websocket" } = opts;
98
100
  const useMeshRegistry = transportMode === "mesh" && inboxInstance;
99
101
 
@@ -291,10 +293,17 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
291
293
  const c = conn || await waitForConn();
292
294
  if (c) {
293
295
  try {
294
- await c.callExtension("trajectory/checkpoint", {
295
- checkpoint: command.checkpoint,
296
- });
297
- respond(client, { ok: true, method: "trajectory" });
296
+ // Include cached resource_id if available from a previous response
297
+ const payload = { checkpoint: command.checkpoint };
298
+ if (_trajectoryResourceId) {
299
+ payload.resource_id = _trajectoryResourceId;
300
+ }
301
+ const result = await c.callExtension("trajectory/checkpoint", payload);
302
+ // Cache resource_id from server response for subsequent calls
303
+ if (result?.resource_id) {
304
+ _trajectoryResourceId = result.resource_id;
305
+ }
306
+ respond(client, { ok: true, method: "trajectory", resource_id: result?.resource_id });
298
307
  } catch (err) {
299
308
  log.warn("trajectory/checkpoint not supported, falling back to broadcast", { error: err.message });
300
309
  await c.send(
@@ -303,9 +312,10 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
303
312
  type: "trajectory.checkpoint",
304
313
  checkpoint: {
305
314
  id: command.checkpoint.id,
306
- agentId: command.checkpoint.agentId,
307
- sessionId: command.checkpoint.sessionId,
308
- label: command.checkpoint.label,
315
+ agent: command.checkpoint.agent,
316
+ session_id: command.checkpoint.session_id,
317
+ files_touched: command.checkpoint.files_touched,
318
+ token_usage: command.checkpoint.token_usage,
309
319
  metadata: command.checkpoint.metadata,
310
320
  },
311
321
  },
@@ -380,6 +390,43 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
380
390
  break;
381
391
  }
382
392
 
393
+ // --- Memory/skill sync → MAP (x-openhive vendor extensions) ---
394
+
395
+ case "bridge-memory-sync": {
396
+ const c = conn || await waitForConn();
397
+ if (c) {
398
+ try {
399
+ // Use callExtension for JSON-RPC vendor-prefixed method
400
+ // Same pattern as trajectory/checkpoint
401
+ const params = {
402
+ resource_id: _memoryResourceId || "",
403
+ agent_id: command.agentId || "minimem",
404
+ commit_hash: `memory-${Date.now()}`,
405
+ timestamp: command.timestamp || new Date().toISOString(),
406
+ };
407
+ const result = await c.callExtension("x-openhive/memory.sync", params);
408
+ // Cache resource_id from server response
409
+ if (result?.resource_id) {
410
+ _memoryResourceId = result.resource_id;
411
+ }
412
+ respond(client, { ok: true, method: "memory-sync", resource_id: result?.resource_id });
413
+ } catch (err) {
414
+ log.warn("x-openhive/memory.sync not supported, falling back to broadcast", { error: err.message });
415
+ // Fallback: emit as a regular message
416
+ await c.send({ scope }, {
417
+ type: "memory.sync",
418
+ agent_id: command.agentId || "minimem",
419
+ timestamp: command.timestamp,
420
+ _origin: command.agentId || "minimem",
421
+ }, { relationship: "broadcast" });
422
+ respond(client, { ok: true, method: "broadcast-fallback" });
423
+ }
424
+ } else {
425
+ respond(client, { ok: false, error: "no connection" });
426
+ }
427
+ break;
428
+ }
429
+
383
430
  case "state": {
384
431
  const c = conn || await waitForConn();
385
432
  if (c) {