claude-code-swarm 0.3.25 → 0.4.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/src/bootstrap.mjs CHANGED
@@ -70,6 +70,9 @@ function getRequiredGlobalPackages(config) {
70
70
  if (config.mesh?.enabled) {
71
71
  packages.push("agentic-mesh");
72
72
  }
73
+ if (config.cascade?.enabled) {
74
+ packages.push("git-cascade"); // declared as a peer in package.json (>=0.0.9 — CascadeCapability + diff-rpc/action-rpc subpaths)
75
+ }
73
76
  return packages;
74
77
  }
75
78
 
@@ -244,16 +247,22 @@ async function startSessionSidecar(config, scope, dir, sessionId) {
244
247
 
245
248
  const ok = await startSidecar(config, dir, sessionId);
246
249
  if (ok) {
247
- // Register the main Claude Code session agent with the MAP server
250
+ // Register the main Claude Code session agent with the MAP server.
251
+ // Use the inbox-derived ID (`${teamName}-main`) as the canonical agentId
252
+ // so MAP and inbox identities are unified — the hub can correlate a MAP
253
+ // agent with its inbox participant without a separate lookup table.
254
+ // The ephemeral sessionId is preserved in metadata for trajectory
255
+ // correlation and session storage.
248
256
  const teamName = resolveTeamName(config);
257
+ const inboxAgentId = `${teamName}-main`;
249
258
  sendCommand(config, {
250
259
  action: "spawn",
251
260
  agent: {
252
- agentId: sessionId,
253
- name: `${teamName}-main`,
261
+ agentId: inboxAgentId,
262
+ name: inboxAgentId,
254
263
  role: "orchestrator",
255
264
  scopes: [scope],
256
- metadata: { isMain: true, sessionId },
265
+ metadata: { isMain: true, sessionId, inboxAgentId },
257
266
  },
258
267
  }, sessionId).catch(() => {});
259
268
 
@@ -307,9 +316,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
307
316
  }
308
317
  }
309
318
 
310
- // Inbox registration
319
+ // Inbox registration — uses the same stable ID as the MAP registration
320
+ // above so both systems share a single canonical agent identity.
311
321
  if (config.map.enabled && config.inbox?.enabled) {
312
- const teamName = resolveTeamName(config);
322
+ const inboxTeamName = resolveTeamName(config);
323
+ const inboxId = `${inboxTeamName}-main`;
313
324
  const sPaths = sessionId
314
325
  ? (await import("./paths.mjs")).sessionPaths(sessionId)
315
326
  : { inboxSocketPath: (await import("./paths.mjs")).INBOX_SOCKET_PATH };
@@ -319,11 +330,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
319
330
  event: {
320
331
  type: "agent.spawn",
321
332
  agent: {
322
- agentId: `${teamName}-main`,
323
- name: `${teamName}-main`,
333
+ agentId: inboxId,
334
+ name: inboxId,
324
335
  role: "orchestrator",
325
336
  scopes: [scope],
326
- metadata: { isMain: true, sessionId },
337
+ metadata: { isMain: true, sessionId, inboxAgentId: inboxId },
327
338
  },
328
339
  },
329
340
  }, sPaths.inboxSocketPath).catch(() => {})
@@ -0,0 +1,334 @@
1
+ /**
2
+ * cascade-client.mjs — git-cascade tracker ownership for claude-code-swarm
3
+ *
4
+ * Owns a persistent `MultiAgentRepoTracker` running in **local mode** as a
5
+ * local state store. cc-swarm emits `x-cascade/*` events itself over the MAP
6
+ * connection (see cascade-events.mjs) — the tracker here is NOT given an
7
+ * `emit` callback. This is the "observed git" design: the tracker mirrors the
8
+ * working branch as a local-mode stream so later phases can observe commits
9
+ * and merges without owning the branch.
10
+ *
11
+ * Every git-cascade call is wrapped in try/catch — cascade failures must never
12
+ * crash the sidecar. When git-cascade can't be imported (not installed) or the
13
+ * repo path is not a git repo, openCascadeTracker() returns null and callers
14
+ * skip cascade work entirely.
15
+ */
16
+
17
+ import { execFileSync } from "child_process";
18
+ import { createLogger } from "./log.mjs";
19
+ import { resolvePackage } from "./swarmkit-resolver.mjs";
20
+
21
+ const log = createLogger("cascade-client");
22
+
23
+ /**
24
+ * Cached git-cascade module namespace exports (`streams`, `changes`).
25
+ *
26
+ * git-cascade exposes its CRUD helpers as `export * as streams` /
27
+ * `export * as changes`. We resolve them lazily on first use and cache them so
28
+ * the DB-record helpers below don't re-import on every call.
29
+ */
30
+ let _gitCascadeMod = null;
31
+ async function getGitCascadeMod() {
32
+ if (_gitCascadeMod) return _gitCascadeMod;
33
+ try {
34
+ const mod = await resolvePackage("git-cascade");
35
+ if (mod) _gitCascadeMod = mod;
36
+ return mod;
37
+ } catch (err) {
38
+ log.warn("git-cascade module resolve failed", { error: err.message });
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Table prefix for the git-cascade tracker DB. Keeps cascade tables namespaced
45
+ * within the SQLite file in case the DB is ever shared.
46
+ */
47
+ const TABLE_PREFIX = "cascade_";
48
+
49
+ /**
50
+ * Check whether `repoPath` is inside a git working tree.
51
+ * Best-effort — returns false on any error.
52
+ */
53
+ function isGitRepo(repoPath) {
54
+ try {
55
+ const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
56
+ cwd: repoPath,
57
+ encoding: "utf-8",
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ }).trim();
60
+ return out === "true";
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Open a git-cascade MultiAgentRepoTracker in local mode.
68
+ *
69
+ * The tracker acts purely as a local state store — no `emit` callback is
70
+ * passed because cc-swarm emits x-cascade/* events itself over MAP.
71
+ *
72
+ * @param {object} opts
73
+ * @param {string} opts.repoPath Path to the git repository (the swarm cwd)
74
+ * @param {string} opts.dbPath Path to the tracker SQLite DB file
75
+ * @returns {Promise<object|null>} The tracker instance, or null if git-cascade
76
+ * is unavailable or `repoPath` is not a git repo. Never throws.
77
+ */
78
+ export async function openCascadeTracker({ repoPath, dbPath }) {
79
+ if (!repoPath || !isGitRepo(repoPath)) {
80
+ log.warn("cascade disabled: not a git repository", { repoPath });
81
+ return null;
82
+ }
83
+
84
+ let gitCascade;
85
+ try {
86
+ gitCascade = await resolvePackage("git-cascade");
87
+ } catch (err) {
88
+ log.warn("cascade disabled: git-cascade import failed", { error: err.message });
89
+ return null;
90
+ }
91
+ if (!gitCascade || typeof gitCascade.MultiAgentRepoTracker !== "function") {
92
+ log.warn("cascade disabled: git-cascade not available");
93
+ return null;
94
+ }
95
+
96
+ try {
97
+ const tracker = new gitCascade.MultiAgentRepoTracker({
98
+ repoPath,
99
+ dbPath,
100
+ tablePrefix: TABLE_PREFIX,
101
+ // Skip startup recovery — local-mode tracking has no in-flight
102
+ // operations to recover, and recovery touches git state we don't own.
103
+ skipRecovery: true,
104
+ });
105
+ log.info("cascade tracker opened", { repoPath, dbPath });
106
+ return tracker;
107
+ } catch (err) {
108
+ log.warn("cascade disabled: failed to open tracker", { error: err.message });
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Resolve the stream id that tracks `branch`, or null when none does.
115
+ *
116
+ * Streams registered via `trackExistingBranch` are local-mode —
117
+ * `getStreamBranchName` returns the existing branch they track. Best-effort:
118
+ * returns null on any error and skips streams whose branch can't be read.
119
+ *
120
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
121
+ * @param {string} branch Branch name to match
122
+ * @returns {string|null} The matching stream id, or null.
123
+ */
124
+ export function findStreamByBranch(tracker, branch) {
125
+ if (!tracker || !branch) return null;
126
+ try {
127
+ const existing = tracker.listStreams();
128
+ for (const stream of existing) {
129
+ let trackedBranch;
130
+ try {
131
+ trackedBranch = tracker.getStreamBranchName(stream.id);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (trackedBranch === branch) return stream.id;
136
+ }
137
+ } catch (err) {
138
+ log.warn("findStreamByBranch failed", { branch, error: err.message });
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Ensure a stream exists for the given branch (idempotent).
145
+ *
146
+ * If a stream already tracks `branch`, returns its id with `created: false`.
147
+ * Otherwise calls `trackExistingBranch` to register the branch as a
148
+ * local-mode stream and returns the new id with `created: true`.
149
+ *
150
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
151
+ * @param {object} opts
152
+ * @param {string} opts.branch Branch name to track
153
+ * @param {string} opts.agentId Owning agent id
154
+ * @param {string} [opts.parentStream] Parent stream id, if forked
155
+ * @returns {{ streamId: string, created: boolean } | null} Null on failure.
156
+ */
157
+ export function ensureStream(tracker, { branch, agentId, parentStream } = {}) {
158
+ if (!tracker || !branch) return null;
159
+
160
+ try {
161
+ const existingId = findStreamByBranch(tracker, branch);
162
+ if (existingId) {
163
+ return { streamId: existingId, created: false };
164
+ }
165
+
166
+ const streamId = tracker.trackExistingBranch({
167
+ branch,
168
+ name: branch,
169
+ agentId,
170
+ parentStream,
171
+ });
172
+ return { streamId, created: true };
173
+ } catch (err) {
174
+ log.warn("ensureStream failed", { branch, error: err.message });
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Record an observed commit as a git-cascade change.
181
+ *
182
+ * Calls `changes.createChange` against the tracker's DB. createChange does NOT
183
+ * emit any event — that is intentional: the cascade-watcher emits the
184
+ * `x-cascade/stream.committed` event itself over MAP. This helper exists only
185
+ * to persist the change row and hand back the git-cascade change id so the
186
+ * watcher can stamp `change_id` on the emitted event.
187
+ *
188
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
189
+ * @param {object} opts
190
+ * @param {string} opts.streamId Stream that received the commit
191
+ * @param {string} opts.commit Commit SHA
192
+ * @param {string} opts.description Change description (commit summary)
193
+ * @returns {Promise<string|null>} The git-cascade change id, or null on failure.
194
+ */
195
+ export async function recordObservedCommit(tracker, { streamId, commit, description } = {}) {
196
+ if (!tracker || !tracker.db || !streamId || !commit) return null;
197
+ try {
198
+ const mod = await getGitCascadeMod();
199
+ if (!mod?.changes?.createChange) return null;
200
+ return mod.changes.createChange(tracker.db, {
201
+ streamId,
202
+ commit,
203
+ description: description || "",
204
+ });
205
+ } catch (err) {
206
+ log.warn("recordObservedCommit failed", { streamId, commit, error: err.message });
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Record an observed merge as a git-cascade stream merge edge.
213
+ *
214
+ * Calls `streams.recordMerge` against the tracker's DB. Like
215
+ * `recordObservedCommit`, this only persists the merge row — the
216
+ * cascade-watcher emits the `x-cascade/stream.merged` event itself.
217
+ *
218
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
219
+ * @param {object} opts
220
+ * @param {string} opts.sourceStreamId Stream merged FROM
221
+ * @param {string} opts.sourceCommit Commit SHA in the source stream
222
+ * @param {string} opts.targetStreamId Stream merged INTO
223
+ * @param {string} opts.mergeCommit Resulting merge commit SHA
224
+ * @param {object} [opts.metadata] Free-form metadata
225
+ * @returns {Promise<string|null>} The merge record id, or null on failure.
226
+ */
227
+ export async function recordObservedMerge(tracker, { sourceStreamId, sourceCommit, targetStreamId, mergeCommit, metadata } = {}) {
228
+ if (!tracker || !tracker.db || !sourceStreamId || !targetStreamId || !mergeCommit) return null;
229
+ try {
230
+ const mod = await getGitCascadeMod();
231
+ if (!mod?.streams?.recordMerge) return null;
232
+ return mod.streams.recordMerge(tracker.db, {
233
+ sourceStreamId,
234
+ sourceCommit: sourceCommit || "",
235
+ targetStreamId,
236
+ mergeCommit,
237
+ metadata: metadata || {},
238
+ });
239
+ } catch (err) {
240
+ log.warn("recordObservedMerge failed", { sourceStreamId, targetStreamId, mergeCommit, error: err.message });
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Record an observed in-progress merge conflict as a git-cascade conflict row.
247
+ *
248
+ * Calls `conflicts.createConflict` against the tracker's DB so the watcher has
249
+ * a stable `conflict_id` (cf-xxx) to stamp on emitted events. The function
250
+ * mirrors the `recordObserved*` style — no event emission (the watcher emits),
251
+ * try/catch, returns null on any failure so cascade can never crash the
252
+ * sidecar.
253
+ *
254
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
255
+ * @param {object} opts
256
+ * @param {string} opts.streamId Stream that became conflicted
257
+ * @param {string} opts.conflictingCommit Commit being applied (MERGE_HEAD)
258
+ * @param {string} opts.targetCommit Commit being applied onto (HEAD)
259
+ * @param {string[]} opts.conflictedFiles Files reported by git as conflicted
260
+ * @returns {Promise<string|null>} The git-cascade conflict id, or null on failure.
261
+ */
262
+ export async function recordObservedConflict(tracker, { streamId, conflictingCommit, targetCommit, conflictedFiles } = {}) {
263
+ if (!tracker || !tracker.db || !streamId || !conflictingCommit) return null;
264
+ try {
265
+ const mod = await getGitCascadeMod();
266
+ if (!mod?.conflicts?.createConflict) return null;
267
+ return mod.conflicts.createConflict(tracker.db, {
268
+ streamId,
269
+ conflictingCommit,
270
+ targetCommit: targetCommit || "",
271
+ conflictedFiles: Array.isArray(conflictedFiles) ? conflictedFiles : [],
272
+ });
273
+ } catch (err) {
274
+ log.warn("recordObservedConflict failed", { streamId, conflictingCommit, error: err.message });
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Record the resolution (or abandonment) of an observed conflict.
281
+ *
282
+ * For methods `"manual"` / `"agent"` / `"ours"` / `"theirs"` this calls
283
+ * `conflicts.resolveConflict`. For `"abandoned"` (the merge was aborted) it
284
+ * calls `conflicts.abandonConflict`, since `ConflictResolution.method` does
285
+ * not include `"abandoned"` in git-cascade's type union.
286
+ *
287
+ * Best-effort — returns false on any failure, never throws.
288
+ *
289
+ * @param {object} tracker A MultiAgentRepoTracker (from openCascadeTracker)
290
+ * @param {object} opts
291
+ * @param {string} opts.conflictId Conflict record id (cf-xxx) returned by
292
+ * `recordObservedConflict`
293
+ * @param {string} opts.method "manual" | "agent" | "abandoned" | "ours" | "theirs"
294
+ * @param {string} [opts.resolvedBy] Agent id or "human"
295
+ * @param {string} [opts.summary] Optional free-form resolution summary
296
+ * @returns {Promise<boolean>} True on success, false on any failure.
297
+ */
298
+ export async function recordObservedConflictResolved(tracker, { conflictId, method, resolvedBy, summary } = {}) {
299
+ if (!tracker || !tracker.db || !conflictId) return false;
300
+ try {
301
+ const mod = await getGitCascadeMod();
302
+ if (!mod?.conflicts) return false;
303
+ if (method === "abandoned") {
304
+ if (typeof mod.conflicts.abandonConflict !== "function") return false;
305
+ mod.conflicts.abandonConflict(tracker.db, conflictId);
306
+ return true;
307
+ }
308
+ if (typeof mod.conflicts.resolveConflict !== "function") return false;
309
+ mod.conflicts.resolveConflict(tracker.db, conflictId, {
310
+ method: method || "manual",
311
+ resolvedBy: resolvedBy || "human",
312
+ ...(summary ? { details: summary } : {}),
313
+ });
314
+ return true;
315
+ } catch (err) {
316
+ log.warn("recordObservedConflictResolved failed", { conflictId, method, error: err.message });
317
+ return false;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Safely close a cascade tracker. No-op when `tracker` is falsy. Never throws.
323
+ *
324
+ * @param {object|null} tracker
325
+ */
326
+ export function closeCascadeTracker(tracker) {
327
+ if (!tracker) return;
328
+ try {
329
+ tracker.close();
330
+ log.debug("cascade tracker closed");
331
+ } catch (err) {
332
+ log.warn("failed to close cascade tracker", { error: err.message });
333
+ }
334
+ }