claude-code-swarm 0.3.26 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.26",
3
+ "version": "0.4.0",
4
4
  "description": "Launch Claude Code with swarmkit capabilities, including team orchestration, MAP observability, and session tracking.",
5
5
  "owner": {
6
6
  "name": "alexngai"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
3
  "description": "Spin up Claude Code agent teams from openteams YAML topologies with optional MAP (Multi-Agent Protocol) observability and coordination. Provides hooks for session lifecycle, agent spawn/complete tracking, and a /swarm skill to launch team configurations.",
4
- "version": "0.3.26",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "alexngai"
7
7
  },
package/CLAUDE.md CHANGED
@@ -226,6 +226,98 @@ Skill-tree options:
226
226
  - `basePath` — Path to skill-tree storage directory (default: `.swarm/skill-tree/`)
227
227
  - `defaultProfile` — Default profile when no role-specific criteria exist (default: `""`)
228
228
 
229
+ ### Cascade integration (Phases 1–2)
230
+ ```json
231
+ {
232
+ "template": "gsd",
233
+ "map": { "server": "ws://localhost:8080" },
234
+ "cascade": {
235
+ "enabled": true
236
+ }
237
+ }
238
+ ```
239
+
240
+ When enabled, the MAP sidecar opens a persistent `git-cascade` tracker
241
+ (`MultiAgentRepoTracker`) in **local mode** as a local state store under
242
+ `.swarm/claude-swarm/tmp/cascade/tracker.db`. On boot it registers the
243
+ repository's current working branch as a local-mode git-cascade stream and
244
+ emits a single `x-cascade/stream.opened` notification over the MAP connection
245
+ so an OpenHive hub's cascade subsystem can observe the swarm.
246
+
247
+ **Phase 2 — observed git.** The sidecar also runs a poll-based ref watcher
248
+ (`src/cascade-watcher.mjs`). Every ~3s it diffs `git for-each-ref` snapshots
249
+ and, when it detects git activity, emits `x-cascade/*` events with real git
250
+ data: `stream.committed` (per new commit, with message summary / files touched
251
+ / parent / git-cascade change id), `stream.merged` (for merge commits, with
252
+ best-effort source-stream resolution via the 2nd parent), `stream.pushed`
253
+ (when a remote-tracking ref catches up to a local branch), and `stream.opened`
254
+ (for newly-appeared branches + a connection re-assert on MAP reconnect). The
255
+ watcher is the **detector**; it works fully standalone, emitting unattributed
256
+ events when no attribution is present.
257
+
258
+ After the ref-diff pass each tick the watcher also probes in-progress
259
+ **merge-conflict** state: a cheap `existsSync` on the worktree-local
260
+ `.git/MERGE_HEAD`. On the off→on transition it records a conflict row
261
+ (`recordObservedConflict` → git-cascade `conflicts.createConflict`) and emits
262
+ `x-cascade/stream.conflicted` with the conflicted files (`git diff
263
+ --name-only --diff-filter=U`), the conflicting commit (`MERGE_HEAD`), the
264
+ target commit (`HEAD`), and `source: "merge"`. On the on→off transition it
265
+ discriminates by HEAD: if HEAD advanced to a commit with ≥2 parents the merge
266
+ was resolved + committed (`resolution_method: "manual"`, or `"agent"` when a
267
+ fresh attribution hint is present) — otherwise the merge was aborted (HEAD
268
+ unchanged → `"abandoned"`, via `conflicts.abandonConflict`). Conflict events
269
+ fire only on transitions, never on every tick while a conflict is open. The
270
+ sidecar advertises `cascade.emitsConflicts: true` honestly off the back of
271
+ this. Rebase-conflict observation (`.git/rebase-merge/`,
272
+ `.git/rebase-apply/`) is a known future expansion — v1 covers `git merge`
273
+ conflicts only.
274
+
275
+ A `PostToolUse(Bash)` hook supplies **attribution only** — it does not detect
276
+ git. It builds an `{ agentId, taskRef, ts }` hint and pushes it to the sidecar
277
+ via the `cascade-attribution` socket command. The watcher stamps `agent_id` /
278
+ `metadata.task_ref` on emitted events when a fresh hint (within ~30s) exists.
279
+ `taskRef` is correlated against the in-progress opentasks task only when
280
+ `opentasks.enabled`; without opentasks, attribution carries `agent_id` only.
281
+
282
+ When the watcher detects a branch forked from a tracked branch it links the
283
+ fork: the `x-cascade/stream.opened` event carries `parent_stream` (the parent
284
+ branch's stream id) and the git-cascade tracker DB records the parent edge, so
285
+ an OpenHive hub's PR-stack walker can traverse parent → child.
286
+
287
+ **Phase 3 — diff serving.** The sidecar wires a diff server
288
+ (`src/cascade-diff-server.mjs`) that registers an inbound `cascade/diff.request`
289
+ handler on the MAP connection, so an OpenHive hub's cascade changelog/diff
290
+ endpoints work for cc-swarm-tracked streams. On a request the server resolves
291
+ the diff with plain git in the repo cwd — cc-swarm runs git-cascade in local
292
+ mode with no worktrees, so the request's `head` / `base` (commit hashes,
293
+ self-identifying) drive `git show <head>` (single commit) or
294
+ `git diff <base>..<head>` (range), with `--name-only` when `files_only` is set
295
+ and a `file_paths` path restriction when present. `files_touched` is always
296
+ computed via `--name-only`. A 50 MB stdout cap and 30 s timeout bound the git
297
+ spawn. The server replies inline (`cascade/diff.response`, `streaming: false`)
298
+ when the raw diff is ≤ 512 KB; larger diffs send a streaming announcement
299
+ (`streaming: true`) followed by 1 MB base64 `cascade/diff.chunk` notifications,
300
+ seq-ordered, with `final: true` + a sha256 over the full payload on the last
301
+ chunk. Any error (bad request, git failure) folds into the typed
302
+ `cascade/diff.response` error variant — the server never throws.
303
+
304
+ The sidecar declares the `cascade: { canServeDiff: true }` MAP capability at
305
+ registration, **conditional on `cascade.enabled`** — the same gate the diff
306
+ server is wired on. Declaring `canServeDiff` without the server would invite
307
+ `cascade/diff.request` notifications from the hub that time out.
308
+
309
+ cc-swarm emits `x-cascade/*` events itself over MAP — the tracker is not given
310
+ git-cascade's `emit` callback. Cascade is only meaningful when `map` is enabled
311
+ (there is otherwise no connection to emit on); when `map` is disabled it is an
312
+ inert no-op rather than a hard failure. All cascade work is fully gated on
313
+ `cascade.enabled` and wrapped so a cascade failure can never crash the sidecar.
314
+
315
+ Cascade options:
316
+ - `enabled` — Enable git-cascade integration (default: `false`)
317
+
318
+ Requires `git-cascade` (`>= ^0.0.7`), installed on demand via swarmkit during
319
+ bootstrap when `cascade.enabled`.
320
+
229
321
  ### Logging
230
322
  ```json
231
323
  {
@@ -303,6 +395,7 @@ All config values can be overridden via `SWARM_*` environment variables. Priorit
303
395
  | `skilltree.enabled` | `SWARM_SKILLTREE_ENABLED` | boolean (`true`/`1`/`yes`) | `false` |
304
396
  | `skilltree.basePath` | `SWARM_SKILLTREE_BASE_PATH` | string | `""` |
305
397
  | `skilltree.defaultProfile` | `SWARM_SKILLTREE_DEFAULT_PROFILE` | string | `""` |
398
+ | `cascade.enabled` | `SWARM_CASCADE_ENABLED` | boolean (`true`/`1`/`yes`) | `false` |
306
399
  | `mesh.enabled` | `SWARM_MESH_ENABLED` | boolean (`true`/`1`/`yes`) | `false` |
307
400
  | `mesh.peerId` | `SWARM_MESH_PEER_ID` | string | `""` |
308
401
  | `mesh.mapServer` | `SWARM_MESH_MAP_SERVER` | string | `""` |
@@ -404,6 +497,7 @@ Both modes:
404
497
  - `trajectory: { canReport: true, canServeContent: true }` — reports checkpoints, serves transcript content on demand
405
498
  - `tasks: { canCreate, canAssign, canUpdate, canList }` — task management
406
499
  - `opentasks: { canQuery, canLink, canAnnotate, canTask }` — conditional, when task_graph configured
500
+ - `cascade: { canServeDiff: true }` — conditional, when `cascade.enabled` — sidecar serves unified diffs on demand via `cascade/diff.request` (see Cascade integration above)
407
501
 
408
502
  Message delivery is **pull-based**: the `UserPromptSubmit` hook reads the inbox on each turn and injects messages into Claude Code's prompt context. No real-time push delivery.
409
503
 
@@ -465,6 +559,7 @@ Global (managed by swarmkit, installed on demand during bootstrap):
465
559
  - **sessionlog** — git-integrated session capture (installed when `sessionlog.enabled: true`)
466
560
  - **minimem** — file-based memory with vector search (installed when `minimem.enabled: true`)
467
561
  - **skill-tree** — versioned skill library with serving layer (installed when `skilltree.enabled: true`)
562
+ - **git-cascade** — multi-agent git tracking; local-mode tracker for cascade observability (installed when `cascade.enabled: true`)
468
563
 
469
564
  Runtime:
470
565
  - **Claude Code agent teams** — enabled via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in settings.json
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Ngai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/hooks/hooks.json CHANGED
@@ -80,6 +80,15 @@
80
80
  }
81
81
  ]
82
82
  },
83
+ {
84
+ "matcher": "Bash",
85
+ "hooks": [
86
+ {
87
+ "type": "command",
88
+ "command": "node -e \"const f=require('fs'),p=require('path'),h=require('os').homedir();let c={};try{c=JSON.parse(f.readFileSync('.swarm/claude-swarm/config.json','utf-8'))}catch{try{c=JSON.parse(f.readFileSync(p.join(h,'.claude-swarm','config.json'),'utf-8'))}catch{}};process.exit((c.cascade?.enabled||process.env.SWARM_CASCADE_ENABLED)&&(c.map?.enabled||c.map?.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED)?0:1)\" 2>/dev/null && node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" cascade-bash-attribution"
89
+ }
90
+ ]
91
+ },
83
92
  {
84
93
  "matcher": "TaskCreate",
85
94
  "hooks": [
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.26",
3
+ "version": "0.4.0",
4
4
  "description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
5
+ "author": "Alex Ngai",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/alexngai/claude-code-swarm.git"
10
+ },
5
11
  "type": "module",
6
12
  "exports": {
7
13
  ".": "./src/index.mjs",
@@ -24,6 +30,7 @@
24
30
  },
25
31
  "peerDependencies": {
26
32
  "agent-inbox": "*",
33
+ "git-cascade": ">=0.0.9",
27
34
  "opentasks": ">=0.1.1",
28
35
  "swarmkit": "*"
29
36
  },
@@ -31,6 +38,9 @@
31
38
  "agent-inbox": {
32
39
  "optional": true
33
40
  },
41
+ "git-cascade": {
42
+ "optional": true
43
+ },
34
44
  "opentasks": {
35
45
  "optional": true
36
46
  }
@@ -45,6 +55,7 @@
45
55
  "test:e2e:tier4": "vitest run --config e2e/vitest.config.e2e.mjs -t tier4",
46
56
  "test:e2e:tier5": "vitest run --config e2e/vitest.config.e2e.mjs -t tier5",
47
57
  "test:e2e:tier7": "vitest run --config e2e/vitest.config.e2e.mjs -t tier7",
58
+ "prepublishOnly": "publint",
48
59
  "version:patch": "npm version patch --no-git-tag-version && node scripts/sync-version.mjs",
49
60
  "version:minor": "npm version minor --no-git-tag-version && node scripts/sync-version.mjs",
50
61
  "version:major": "npm version major --no-git-tag-version && node scripts/sync-version.mjs"
@@ -54,8 +65,10 @@
54
65
  },
55
66
  "devDependencies": {
56
67
  "agent-inbox": "^0.2.3",
68
+ "git-cascade": "^0.0.9",
57
69
  "minimem": "^0.1.1",
58
70
  "opentasks": "^0.1.2",
71
+ "publint": "^0.3.21",
59
72
  "skill-tree": "^0.2.0",
60
73
  "vitest": "^4.0.18",
61
74
  "ws": "^8.0.0"
package/renovate.json5 ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ // Dependency-update policy is shared across the swarm/openhive ecosystem.
3
+ // See github.com/alexngai/swarm-renovate-config.
4
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
5
+ "extends": ["github>alexngai/swarm-renovate-config"]
6
+ }
@@ -20,6 +20,7 @@
20
20
  * task-completed — Complete task in opentasks + emit bridge event
21
21
  * opentasks-mcp-used — Bridge opentasks MCP tool use → MAP task sync payload
22
22
  * sessionlog-dispatch — Dispatch a sessionlog lifecycle hook via programmatic API
23
+ * cascade-bash-attribution — Push a cascade attribution hint to the sidecar
23
24
  *
24
25
  * Usage: node map-hook.mjs <action>
25
26
  * Hook event data is read from stdin (JSON).
@@ -36,7 +37,7 @@ configureNodePath();
36
37
  const log = createLogger("map-hook");
37
38
  import { readRoles, matchRole } from "../src/roles.mjs";
38
39
  import { formatInboxAsMarkdown } from "../src/inbox.mjs";
39
- import { sendToInbox } from "../src/sidecar-client.mjs";
40
+ import { sendToInbox, sendToSidecar } from "../src/sidecar-client.mjs";
40
41
  import { sessionPaths } from "../src/paths.mjs";
41
42
  import {
42
43
  sendCommand,
@@ -51,7 +52,7 @@ import {
51
52
  buildMinimemBridgeCommand,
52
53
  } from "../src/map-events.mjs";
53
54
  import { syncSessionlog, dispatchSessionlogHook } from "../src/sessionlog.mjs";
54
- import { findSocketPath, pushSyncEvent } from "../src/opentasks-client.mjs";
55
+ import { findSocketPath, pushSyncEvent, rpcRequest } from "../src/opentasks-client.mjs";
55
56
 
56
57
  const action = process.argv[2];
57
58
 
@@ -232,6 +233,60 @@ async function handleMinimemMcpUsed(hookData, sessionId) {
232
233
  }
233
234
  }
234
235
 
236
+ // ── cascade attribution (PostToolUse Bash) ──────────────────────────────────
237
+ //
238
+ // Attribution-only: this does NOT parse the git command or detect git. The
239
+ // cascade-watcher in the sidecar is the detector. This hook just builds an
240
+ // attribution hint { agentId, taskRef, ts } and pushes it to the sidecar as a
241
+ // `cascade-attribution` command — the watcher reads the freshest hint to
242
+ // stamp agent_id / task_ref on observed-git events.
243
+
244
+ async function handleCascadeBashAttribution(hookData, sessionId) {
245
+ const config = readConfig();
246
+ if (!config.cascade?.enabled || !config.map?.enabled) return;
247
+
248
+ // Acting agent id — same resolution other hooks use: hook data first,
249
+ // then env. Falls back to the session id when nothing else identifies it.
250
+ const agentId =
251
+ hookData.agent_id ||
252
+ process.env.CLAUDE_AGENT_ID ||
253
+ process.env.MACRO_AGENT_ID ||
254
+ sessionId ||
255
+ "";
256
+
257
+ // taskRef — only correlated when opentasks is enabled. When opentasks is
258
+ // disabled we skip the query entirely and leave taskRef null; no task
259
+ // correlation is expected without opentasks (intended).
260
+ let taskRef = null;
261
+ if (config.opentasks?.enabled && agentId) {
262
+ try {
263
+ const otSocketPath = findSocketPath();
264
+ const nodes = await rpcRequest(
265
+ "graph.query",
266
+ { type: "task", filter: { status: "in_progress", assignee: agentId } },
267
+ otSocketPath,
268
+ );
269
+ const task = Array.isArray(nodes) ? nodes[0] : null;
270
+ if (task) {
271
+ // resource_id intentionally omitted — the hub resolves it from the
272
+ // swarm's registered task graph. Emitting a guessed value causes false
273
+ // bindings.
274
+ taskRef = {
275
+ node_id: task.node_id || task.id || "",
276
+ };
277
+ }
278
+ } catch {
279
+ // Best-effort — leave taskRef null on any failure.
280
+ }
281
+ }
282
+
283
+ const sPaths = sessionPaths(sessionId);
284
+ await sendToSidecar(
285
+ { action: "cascade-attribution", agentId, taskRef, ts: Date.now() },
286
+ sPaths.socketPath,
287
+ );
288
+ }
289
+
235
290
  // ── Main ──────────────────────────────────────────────────────────────────────
236
291
 
237
292
  async function main() {
@@ -253,6 +308,7 @@ async function main() {
253
308
  case "native-task-created": await handleNativeTaskCreated(hookData, sessionId); break;
254
309
  case "native-task-updated": await handleNativeTaskUpdated(hookData, sessionId); break;
255
310
  case "minimem-mcp-used": await handleMinimemMcpUsed(hookData, sessionId); break;
311
+ case "cascade-bash-attribution": await handleCascadeBashAttribution(hookData, sessionId); break;
256
312
  case "sessionlog-dispatch": await handleSessionlogDispatch(hookData); break;
257
313
  default:
258
314
  log.warn("unknown action", { action });
@@ -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
 
@@ -461,6 +522,7 @@ async function startWebSocketTransport() {
461
522
  credential: AUTH_CREDENTIAL || undefined,
462
523
  projectContext: PROJECT_CONTEXT,
463
524
  inboxEnabled: !!INBOX_CONFIG || MESH_ENABLED,
525
+ cascadeEnabled: CASCADE_ENABLED,
464
526
  onMessage: () => {
465
527
  resetInactivityTimer();
466
528
  },
@@ -539,6 +601,116 @@ async function startLegacyAgentInbox(mapConnection) {
539
601
  }
540
602
  }
541
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
+
542
714
  // ── Main ────────────────────────────────────────────────────────────────────
543
715
 
544
716
  async function main() {
@@ -613,6 +785,11 @@ async function main() {
613
785
  }
614
786
  }
615
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
+
616
793
  // Start inactivity timer
617
794
  resetInactivityTimer();
618
795