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.
@@ -20,13 +20,17 @@
20
20
 
21
21
  import fs from "fs";
22
22
  import path from "path";
23
- import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, sessionPaths, pluginDir } from "../src/paths.mjs";
23
+ import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, CASCADE_DB_PATH, sessionPaths, pluginDir, ensureCascadeDir } from "../src/paths.mjs";
24
24
  import { connectToMAP } from "../src/map-connection.mjs";
25
25
  import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
26
26
  import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
27
27
  import { startOpenTasksEventBridge } from "../src/opentasks-bridge.mjs";
28
28
  import { createContentProvider } from "../src/content-provider.mjs";
29
29
  import { startMemoryWatcher } from "../src/memory-watcher.mjs";
30
+ import { openCascadeTracker, ensureStream, closeCascadeTracker } from "../src/cascade-client.mjs";
31
+ import { buildStreamOpenedParams, emitStreamOpened } from "../src/cascade-events.mjs";
32
+ import { startCascadeWatcher } from "../src/cascade-watcher.mjs";
33
+ import { setupCascadeDiffServer } from "../src/cascade-diff-server.mjs";
30
34
  import { readConfig } from "../src/config.mjs";
31
35
  import { createLogger, init as initLog } from "../src/log.mjs";
32
36
  import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
@@ -98,6 +102,16 @@ initLog({ ..._logConfig, sessionId: SESSION_ID || undefined });
98
102
  const MESH_ENABLED = hasFlag("mesh-enabled");
99
103
  const MESH_PEER_ID = getArg("mesh-peer-id", "");
100
104
 
105
+ // Cascade gate — whether git-cascade integration is enabled. Used to declare
106
+ // the `cascade.canServeDiff` MAP capability conditionally: the diff server
107
+ // (src/cascade-diff-server.mjs) is wired in setupCascade() only when this is
108
+ // true, so declaring the capability without it would invite timed-out
109
+ // cascade/diff.request notifications from the hub.
110
+ let CASCADE_ENABLED = false;
111
+ try {
112
+ CASCADE_ENABLED = Boolean(readConfig().cascade?.enabled);
113
+ } catch { /* config unreadable — leave cascade off */ }
114
+
101
115
  // Parse inbox config (passed as JSON blob from sidecar-client)
102
116
  let INBOX_CONFIG = null;
103
117
  const inboxConfigJson = getArg("inbox-config", "");
@@ -125,6 +139,9 @@ let inactivityTimer = null;
125
139
  let reconnectInterval = null;
126
140
  let transportMode = "websocket"; // "mesh" or "websocket"
127
141
  let opentasksBridge = null; // Daemon watch → MAP event bridge (Option A)
142
+ let cascadeTracker = null; // git-cascade local-mode tracker (Phase 1+)
143
+ let cascadeWatcher = null; // observed-git ref watcher (Phase 2)
144
+ let cascadeDiffServerDispose = null; // cascade/diff.request handler cleanup (Phase 3)
128
145
  const registeredAgents = new Map();
129
146
 
130
147
  // ── Inactivity Timer ────────────────────────────────────────────────────────
@@ -152,6 +169,27 @@ async function shutdown() {
152
169
  opentasksBridge = null;
153
170
  }
154
171
 
172
+ // Stop the cascade ref watcher before the tracker DB closes — the watcher
173
+ // calls into the tracker. Safe no-op when cascade was never enabled.
174
+ if (cascadeWatcher) {
175
+ try { cascadeWatcher.stop(); } catch { /* ignore */ }
176
+ cascadeWatcher = null;
177
+ }
178
+
179
+ // Dispose the cascade diff server (unregisters the cascade/diff.request
180
+ // handler). Safe no-op when cascade was never enabled.
181
+ if (cascadeDiffServerDispose) {
182
+ try { cascadeDiffServerDispose(); } catch { /* ignore */ }
183
+ cascadeDiffServerDispose = null;
184
+ }
185
+
186
+ // Close the git-cascade tracker (local state store) before the socket
187
+ // and connection drop. Safe no-op when cascade was never enabled.
188
+ if (cascadeTracker) {
189
+ closeCascadeTracker(cascadeTracker);
190
+ cascadeTracker = null;
191
+ }
192
+
155
193
  // Stop agent-inbox first (it borrows the connection/peer, doesn't own it)
156
194
  if (inboxInstance) {
157
195
  try { await inboxInstance.stop(); } catch { /* ignore */ }
@@ -234,6 +272,7 @@ function startSlowReconnectLoop() {
234
272
  credential: AUTH_CREDENTIAL || undefined,
235
273
  projectContext: PROJECT_CONTEXT,
236
274
  inboxEnabled: !!INBOX_CONFIG || MESH_ENABLED,
275
+ cascadeEnabled: CASCADE_ENABLED,
237
276
  onMessage: () => resetInactivityTimer(),
238
277
  });
239
278
 
@@ -245,6 +284,28 @@ function startSlowReconnectLoop() {
245
284
  if (commandHandler) commandHandler.setConnection(newConn);
246
285
  attachReconnectionListener(newConn);
247
286
 
287
+ // Point the cascade watcher at the fresh connection and re-assert
288
+ // open streams — idempotent on the hub, covers the reconnect gap.
289
+ if (cascadeWatcher) {
290
+ try {
291
+ cascadeWatcher.setConnection(newConn);
292
+ cascadeWatcher.reassertStreams();
293
+ } catch { /* ignore — cascade must never crash the sidecar */ }
294
+ }
295
+
296
+ // Re-register the cascade diff server on the fresh connection — the
297
+ // previous handler was bound to the dead one. No-op when cascade is
298
+ // disabled (dispose handle is null).
299
+ if (cascadeDiffServerDispose) {
300
+ try {
301
+ cascadeDiffServerDispose();
302
+ cascadeDiffServerDispose = setupCascadeDiffServer(newConn, {
303
+ repoPath: process.cwd(),
304
+ tracker: cascadeTracker,
305
+ });
306
+ } catch { /* ignore — cascade must never crash the sidecar */ }
307
+ }
308
+
248
309
  // Re-register active agents so the MAP server knows about them
249
310
  await reRegisterAgents(newConn);
250
311
 
@@ -425,6 +486,34 @@ function registerContentHandler(conn) {
425
486
  });
426
487
  }
427
488
 
489
+ /**
490
+ * Register the x-dispatch/nudge notification handler on a connection.
491
+ * When the hub sends a nudge (a dispatch thread received a new turn),
492
+ * the sidecar stores the nudge so the UserPromptSubmit hook can inject
493
+ * a hint about pending messages.
494
+ */
495
+ function registerNudgeHandler(conn) {
496
+ if (!conn || typeof conn.onNotification !== "function") return;
497
+
498
+ conn.onNotification("x-dispatch/nudge", (params) => {
499
+ const dispatchId = params?.dispatch_id;
500
+ const conversationId = params?.conversation_id;
501
+ if (!dispatchId) return;
502
+
503
+ log.info("dispatch nudge received", { dispatchId, conversationId });
504
+ resetInactivityTimer();
505
+
506
+ // Store the nudge via the command handler's nudge command.
507
+ // Use a fake client since we don't need the response.
508
+ if (commandHandler) {
509
+ commandHandler(
510
+ { action: "nudge", dispatch_id: dispatchId, conversation_id: conversationId },
511
+ { write: () => {}, writable: true },
512
+ );
513
+ }
514
+ });
515
+ }
516
+
428
517
  async function startWebSocketTransport() {
429
518
  connection = await connectToMAP({
430
519
  server: MAP_SERVER,
@@ -433,6 +522,7 @@ async function startWebSocketTransport() {
433
522
  credential: AUTH_CREDENTIAL || undefined,
434
523
  projectContext: PROJECT_CONTEXT,
435
524
  inboxEnabled: !!INBOX_CONFIG || MESH_ENABLED,
525
+ cascadeEnabled: CASCADE_ENABLED,
436
526
  onMessage: () => {
437
527
  resetInactivityTimer();
438
528
  },
@@ -511,6 +601,116 @@ async function startLegacyAgentInbox(mapConnection) {
511
601
  }
512
602
  }
513
603
 
604
+ // ── Cascade (git-cascade local-mode tracking) ───────────────────────────────
605
+
606
+ /**
607
+ * Determine the current branch and its HEAD commit for `repoPath`.
608
+ * Returns null for either field on any git error.
609
+ */
610
+ function getRepoHead(repoPath) {
611
+ let branch = null;
612
+ let commit = null;
613
+ try {
614
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
615
+ cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
616
+ }).trim();
617
+ } catch { /* not a git repo / detached */ }
618
+ try {
619
+ commit = execSync("git rev-parse HEAD", {
620
+ cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
621
+ }).trim();
622
+ } catch { /* no commits yet */ }
623
+ return { branch, commit };
624
+ }
625
+
626
+ /**
627
+ * Cascade setup: open a git-cascade tracker in local mode, register the
628
+ * working branch as a local-mode stream, emit one `x-cascade/stream.opened`
629
+ * event, and start the Phase 2 observed-git ref watcher.
630
+ *
631
+ * Stores the tracker on `cascadeTracker` and the watcher on `cascadeWatcher`.
632
+ * Fully resilient — any failure is logged and swallowed so cascade can never
633
+ * crash the sidecar.
634
+ */
635
+ async function setupCascade() {
636
+ try {
637
+ const cfg = readConfig();
638
+ if (!cfg.cascade?.enabled) return;
639
+
640
+ const repoPath = process.cwd();
641
+ const { branch, commit } = getRepoHead(repoPath);
642
+ if (!branch || branch === "HEAD") {
643
+ log.warn("cascade: no current branch, skipping stream registration");
644
+ return;
645
+ }
646
+
647
+ ensureCascadeDir();
648
+ cascadeTracker = await openCascadeTracker({ repoPath, dbPath: CASCADE_DB_PATH });
649
+ if (!cascadeTracker) return; // openCascadeTracker already logged the reason
650
+
651
+ // First-emit settle window. AgentConnection.connect() resolves once the
652
+ // SDK handshake completes, but the hub-side session-context attach (which
653
+ // stamps the swarmId on inbound messages) can land a beat later. The live
654
+ // e2e (src/__tests__/cascade/live-cc-swarm-cascade-e2e.test.ts) surfaced
655
+ // that a callExtension fired immediately after connect can race the
656
+ // inbound register and be silently dropped. Give the hub a small window
657
+ // to settle before the first cascade emit. The watcher's
658
+ // reassertStreams() (run on watcher start and on every reconnect) is the
659
+ // belt-and-suspenders recovery; this settle is the cheaper first line of
660
+ // defense. Replace with a proper readiness signal if the MAP SDK ever
661
+ // exposes one.
662
+ await new Promise((resolve) => setTimeout(resolve, 100));
663
+
664
+ const teamName = MAP_SCOPE.replace("swarm:", "");
665
+ const agentId = `${teamName}-sidecar`;
666
+
667
+ const result = ensureStream(cascadeTracker, { branch, agentId });
668
+ if (!result) return;
669
+
670
+ if (result.created) {
671
+ log.info("cascade: registered working branch as stream", { branch, streamId: result.streamId });
672
+ const params = buildStreamOpenedParams({
673
+ streamId: result.streamId,
674
+ name: branch,
675
+ agentId,
676
+ baseCommit: commit || "",
677
+ branchName: branch,
678
+ metadata: { trigger: "sidecar-boot" },
679
+ });
680
+ emitStreamOpened(connection, params);
681
+ } else {
682
+ log.debug("cascade: working branch already tracked", { branch, streamId: result.streamId });
683
+ }
684
+
685
+ // Phase 2: start the observed-git ref watcher. It detects commits/merges/
686
+ // pushes from git ref state and emits x-cascade/* events with real git
687
+ // data. Attribution (agent_id, task_ref) comes from the PostToolUse(Bash)
688
+ // hook via the command handler's cascade-attribution side-channel.
689
+ cascadeWatcher = startCascadeWatcher({
690
+ tracker: cascadeTracker,
691
+ connection,
692
+ repoPath,
693
+ getAttribution: commandHandler?.getCascadeAttribution,
694
+ agentId,
695
+ });
696
+
697
+ // Phase 3: wire the cascade diff server. It registers a
698
+ // cascade/diff.request handler so the hub can fetch unified diffs for
699
+ // cc-swarm-tracked streams on demand. Resilient — try/catch keeps a
700
+ // cascade failure from ever crashing the sidecar.
701
+ try {
702
+ cascadeDiffServerDispose = setupCascadeDiffServer(connection, {
703
+ repoPath,
704
+ tracker: cascadeTracker,
705
+ });
706
+ } catch (err) {
707
+ log.warn("cascade diff server setup failed", { error: err.message });
708
+ }
709
+ } catch (err) {
710
+ log.warn("cascade setup failed", { error: err.message });
711
+ }
712
+ }
713
+
514
714
  // ── Main ────────────────────────────────────────────────────────────────────
515
715
 
516
716
  async function main() {
@@ -557,6 +757,10 @@ async function main() {
557
757
  return commandHandler(command, client);
558
758
  });
559
759
 
760
+ // Register dispatch nudge handler — must come after commandHandler is created
761
+ // so the notification can store nudge state via the command handler.
762
+ registerNudgeHandler(connection);
763
+
560
764
  // Start memory file watcher if minimem is enabled
561
765
  const sidecarConfig = readConfig();
562
766
  if (sidecarConfig.minimem?.enabled) {
@@ -581,6 +785,11 @@ async function main() {
581
785
  }
582
786
  }
583
787
 
788
+ // Cascade Phase 1: register the working branch as a local-mode
789
+ // git-cascade stream and emit x-cascade/stream.opened. No-op unless
790
+ // cascade.enabled. Resilient — never crashes the sidecar.
791
+ await setupCascade();
792
+
584
793
  // Start inactivity timer
585
794
  resetInactivityTimer();
586
795
 
@@ -0,0 +1,217 @@
1
+ /**
2
+ * cascade-client.test.mjs — git-cascade tracker ownership (Phase 1)
3
+ *
4
+ * Exercises openCascadeTracker / ensureStream / closeCascadeTracker against a
5
+ * real temp git repo with a couple of branches. git-cascade is resolved at
6
+ * runtime via swarmkit-resolver, so these tests require git-cascade to be
7
+ * importable from the workspace.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { execSync } from "child_process";
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import {
15
+ openCascadeTracker,
16
+ ensureStream,
17
+ closeCascadeTracker,
18
+ findStreamByBranch,
19
+ recordObservedCommit,
20
+ recordObservedMerge,
21
+ } from "../cascade-client.mjs";
22
+ import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
23
+
24
+ /**
25
+ * Create a real git repo with an initial commit and a couple of branches.
26
+ * Leaves `main` (or the default branch) checked out.
27
+ */
28
+ function makeGitRepo(dir) {
29
+ execSync("git init", { cwd: dir, stdio: "pipe" });
30
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
31
+ execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
32
+ execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" });
33
+ fs.writeFileSync(path.join(dir, "README.md"), "# test\n");
34
+ execSync("git add .", { cwd: dir, stdio: "pipe" });
35
+ execSync('git commit -m "initial"', { cwd: dir, stdio: "pipe" });
36
+ // Create a couple of extra branches.
37
+ execSync("git branch feature-a", { cwd: dir, stdio: "pipe" });
38
+ execSync("git branch feature-b", { cwd: dir, stdio: "pipe" });
39
+ }
40
+
41
+ /** Current branch name of a repo. */
42
+ function currentBranch(dir) {
43
+ return execSync("git rev-parse --abbrev-ref HEAD", {
44
+ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
45
+ }).trim();
46
+ }
47
+
48
+ /** HEAD commit SHA of a repo (optionally a specific rev). */
49
+ function headSha(dir, rev = "HEAD") {
50
+ return execSync(`git rev-parse ${rev}`, {
51
+ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
52
+ }).trim();
53
+ }
54
+
55
+ describe("cascade-client", () => {
56
+ let repoDir;
57
+ let dbPath;
58
+ let tracker;
59
+
60
+ beforeEach(() => {
61
+ repoDir = makeTmpDir("cascade-test-");
62
+ dbPath = path.join(repoDir, "tracker.db");
63
+ makeGitRepo(repoDir);
64
+ tracker = null;
65
+ });
66
+
67
+ afterEach(() => {
68
+ if (tracker) {
69
+ closeCascadeTracker(tracker);
70
+ tracker = null;
71
+ }
72
+ cleanupTmpDir(repoDir);
73
+ });
74
+
75
+ it("openCascadeTracker returns a tracker for a git repo", async () => {
76
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
77
+ expect(tracker).not.toBeNull();
78
+ expect(typeof tracker.trackExistingBranch).toBe("function");
79
+ });
80
+
81
+ it("openCascadeTracker returns null for a non-git directory", async () => {
82
+ const nonGit = makeTmpDir("cascade-nongit-");
83
+ try {
84
+ const t = await openCascadeTracker({
85
+ repoPath: nonGit,
86
+ dbPath: path.join(nonGit, "tracker.db"),
87
+ });
88
+ expect(t).toBeNull();
89
+ } finally {
90
+ cleanupTmpDir(nonGit);
91
+ }
92
+ });
93
+
94
+ it("ensureStream creates a stream for the working branch", async () => {
95
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
96
+ expect(tracker).not.toBeNull();
97
+
98
+ const branch = currentBranch(repoDir);
99
+ const result = ensureStream(tracker, { branch, agentId: "team-sidecar" });
100
+
101
+ expect(result).not.toBeNull();
102
+ expect(result.created).toBe(true);
103
+ expect(typeof result.streamId).toBe("string");
104
+ expect(result.streamId.length).toBeGreaterThan(0);
105
+ });
106
+
107
+ it("ensureStream is idempotent on a second call for the same branch", async () => {
108
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
109
+ expect(tracker).not.toBeNull();
110
+
111
+ const branch = currentBranch(repoDir);
112
+ const first = ensureStream(tracker, { branch, agentId: "team-sidecar" });
113
+ const second = ensureStream(tracker, { branch, agentId: "team-sidecar" });
114
+
115
+ expect(first).not.toBeNull();
116
+ expect(second).not.toBeNull();
117
+ expect(first.created).toBe(true);
118
+ expect(second.created).toBe(false);
119
+ expect(second.streamId).toBe(first.streamId);
120
+ });
121
+
122
+ it("ensureStream tracks distinct branches as separate streams", async () => {
123
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
124
+ expect(tracker).not.toBeNull();
125
+
126
+ const a = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
127
+ const b = ensureStream(tracker, { branch: "feature-b", agentId: "team-sidecar" });
128
+
129
+ expect(a).not.toBeNull();
130
+ expect(b).not.toBeNull();
131
+ expect(a.created).toBe(true);
132
+ expect(b.created).toBe(true);
133
+ expect(a.streamId).not.toBe(b.streamId);
134
+ });
135
+
136
+ it("ensureStream returns null when tracker is missing", () => {
137
+ expect(ensureStream(null, { branch: "main", agentId: "x" })).toBeNull();
138
+ });
139
+
140
+ it("closeCascadeTracker is a safe no-op on null", () => {
141
+ expect(() => closeCascadeTracker(null)).not.toThrow();
142
+ });
143
+
144
+ it("closeCascadeTracker closes an open tracker", async () => {
145
+ const t = await openCascadeTracker({ repoPath: repoDir, dbPath });
146
+ expect(t).not.toBeNull();
147
+ expect(() => closeCascadeTracker(t)).not.toThrow();
148
+ });
149
+
150
+ it("findStreamByBranch resolves a tracked branch and returns null otherwise", async () => {
151
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
152
+ expect(tracker).not.toBeNull();
153
+
154
+ const branch = currentBranch(repoDir);
155
+ const created = ensureStream(tracker, { branch, agentId: "team-sidecar" });
156
+ expect(created).not.toBeNull();
157
+
158
+ expect(findStreamByBranch(tracker, branch)).toBe(created.streamId);
159
+ expect(findStreamByBranch(tracker, "no-such-branch")).toBeNull();
160
+ expect(findStreamByBranch(null, branch)).toBeNull();
161
+ });
162
+
163
+ it("recordObservedCommit persists a change and returns its id", async () => {
164
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
165
+ expect(tracker).not.toBeNull();
166
+
167
+ const branch = currentBranch(repoDir);
168
+ const stream = ensureStream(tracker, { branch, agentId: "team-sidecar" });
169
+ expect(stream).not.toBeNull();
170
+
171
+ const commit = headSha(repoDir);
172
+ const changeId = await recordObservedCommit(tracker, {
173
+ streamId: stream.streamId,
174
+ commit,
175
+ description: "initial",
176
+ });
177
+
178
+ expect(typeof changeId).toBe("string");
179
+ expect(changeId.length).toBeGreaterThan(0);
180
+ });
181
+
182
+ it("recordObservedCommit returns null when tracker is missing", async () => {
183
+ const result = await recordObservedCommit(null, {
184
+ streamId: "s1", commit: "abc", description: "x",
185
+ });
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it("recordObservedMerge persists a merge edge and returns its id", async () => {
190
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
191
+ expect(tracker).not.toBeNull();
192
+
193
+ const target = ensureStream(tracker, { branch: currentBranch(repoDir), agentId: "team-sidecar" });
194
+ const source = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
195
+ expect(target).not.toBeNull();
196
+ expect(source).not.toBeNull();
197
+
198
+ const mergeCommit = headSha(repoDir);
199
+ const mergeId = await recordObservedMerge(tracker, {
200
+ sourceStreamId: source.streamId,
201
+ sourceCommit: headSha(repoDir, "feature-a"),
202
+ targetStreamId: target.streamId,
203
+ mergeCommit,
204
+ metadata: { trigger: "test" },
205
+ });
206
+
207
+ expect(typeof mergeId).toBe("string");
208
+ expect(mergeId.length).toBeGreaterThan(0);
209
+ });
210
+
211
+ it("recordObservedMerge returns null when required fields are missing", async () => {
212
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
213
+ expect(tracker).not.toBeNull();
214
+ const result = await recordObservedMerge(tracker, { sourceStreamId: "s1" });
215
+ expect(result).toBeNull();
216
+ });
217
+ });