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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +95 -0
- package/LICENSE +21 -0
- package/hooks/hooks.json +9 -0
- package/package.json +14 -1
- package/renovate.json5 +6 -0
- package/scripts/map-hook.mjs +58 -2
- package/scripts/map-sidecar.mjs +178 -1
- package/src/__tests__/cascade-client.test.mjs +217 -0
- package/src/__tests__/cascade-diff-server.test.mjs +375 -0
- package/src/__tests__/cascade-watcher.test.mjs +475 -0
- package/src/__tests__/config.test.mjs +23 -0
- package/src/bootstrap.mjs +3 -0
- package/src/cascade-client.mjs +334 -0
- package/src/cascade-diff-server.mjs +326 -0
- package/src/cascade-events.mjs +285 -0
- package/src/cascade-watcher.mjs +694 -0
- package/src/config.mjs +7 -0
- package/src/map-connection.mjs +18 -1
- package/src/paths.mjs +12 -0
- package/src/sidecar-server.mjs +26 -0
|
@@ -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.
|
|
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
|
+
"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
package/scripts/map-hook.mjs
CHANGED
|
@@ -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 });
|
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -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
|
|