@desplega.ai/agent-swarm 1.79.1 → 1.79.2

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/README.md CHANGED
@@ -91,7 +91,8 @@ flowchart LR
91
91
  - **Scheduled & recurring tasks** — cron-based automation for standing work. [Scheduling →](https://docs.agent-swarm.dev/docs/concepts/scheduling)
92
92
  - **Multi-provider** — run with Claude Code, OpenAI Codex, pi-mono, Devin, Claude Managed Agents, or opencode. [Harness config →](https://docs.agent-swarm.dev/docs/guides/harness-configuration) · [Add a new provider →](https://docs.agent-swarm.dev/docs/guides/harness-providers)
93
93
  - **Skills & MCP servers** — reusable procedural knowledge and per-agent MCP servers with scope cascade. [MCP tools →](https://docs.agent-swarm.dev/docs/reference/mcp-tools)
94
- - **DB-backed pages** — agents publish HTML or JSON pages (reports, dashboards, action specs) via the `create_page` MCP tool with public / authed / password modes and version history. [MCP tools → Pages](https://docs.agent-swarm.dev/docs/reference/mcp-tools#pages-tools)
94
+ - **DB-backed pages** — agents publish HTML or JSON pages (reports, dashboards, action specs) via the `create_page` MCP tool with public / authed / password modes, version history, view counters, diff helpers, and PDF export. [MCP tools → Pages](https://docs.agent-swarm.dev/docs/reference/mcp-tools#pages-tools)
95
+ - **KV store** — Redis-like namespaced key/value store with auto-scoped context (Slack thread / PR / Linear issue / page). [MCP tools → KV](https://docs.agent-swarm.dev/docs/reference/mcp-tools#kv-tools)
95
96
  - **Real-time dashboard** — monitor agents, tasks, and inter-agent chat. [app.agent-swarm.dev →](https://app.agent-swarm.dev)
96
97
 
97
98
  ## Quick Start
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.79.1",
5
+ "version": "1.79.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.79.1",
3
+ "version": "1.79.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -202,11 +202,14 @@ function buildCsp(): string {
202
202
  // Fonts (`fonts.googleapis.com` stylesheets + `fonts.gstatic.com` font
203
203
  // files) for the swarm default typography, and same-origin /@swarm/api/*
204
204
  // for the Browser SDK. Inline scripts/styles remain allowed so
205
- // agent-emitted styles work.
205
+ // agent-emitted styles work. `cdn.jsdelivr.net` + `unpkg.com` are the two
206
+ // dominant npm-package CDNs (Chart.js, ApexCharts, D3, htmx, Alpine, …) so
207
+ // pages that need a viz library can `<script src="…">` instead of inlining
208
+ // a multi-hundred-KB bundle.
206
209
  return [
207
210
  "default-src 'self'",
208
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com",
209
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.tailwindcss.com",
211
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com",
212
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com",
210
213
  "font-src 'self' https://fonts.gstatic.com data:",
211
214
  "img-src 'self' data: https:",
212
215
  "connect-src 'self'",
@@ -1,4 +1,5 @@
1
- import { unlink, writeFile } from "node:fs/promises";
1
+ import { readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
4
5
  import { validateClaudeCredentials } from "../utils/credentials";
@@ -60,6 +61,97 @@ async function cleanupTaskFile(pid: number): Promise<void> {
60
61
  }
61
62
  }
62
63
 
64
+ /**
65
+ * Parse `CLAUDE_BINARY` into argv prefix tokens.
66
+ *
67
+ * Accepts a single binary name (`"claude"`, `"shannon"`), an absolute path,
68
+ * or a whitespace-separated command string (`"bunx @dexh/shannon"`,
69
+ * `"npx -y @dexh/shannon"`). Trim + split on `/\s+/`. No shell parsing, no
70
+ * quote handling — keep it tiny and predictable. Empty / missing → `["claude"]`.
71
+ *
72
+ * Exported for unit testing.
73
+ */
74
+ export function parseClaudeBinary(raw: string | undefined): string[] {
75
+ const trimmed = (raw ?? "claude").trim();
76
+ if (trimmed === "") return ["claude"];
77
+ return trimmed.split(/\s+/);
78
+ }
79
+
80
+ /**
81
+ * Resolve the effective `CLAUDE_BINARY` for a worker (raw string, pre-parse).
82
+ *
83
+ * Precedence (highest first), mirroring `resolveHarnessProvider`:
84
+ * 1. `resolvedEnv.CLAUDE_BINARY` — overlay from `swarm_config`
85
+ * (scoped repo > agent > global, applied by `fetchResolvedEnv` in
86
+ * `src/commands/runner.ts`). Lets operators flip a worker via
87
+ * `set-config` without a container restart.
88
+ * 2. `fallbackEnv.CLAUDE_BINARY` — raw `process.env` (container env).
89
+ * 3. `"claude"` — final default; no behavior change for users who don't set it.
90
+ *
91
+ * Returns the raw string (caller pipes through `parseClaudeBinary` for argv split).
92
+ *
93
+ * Exported for unit testing.
94
+ */
95
+ export function resolveClaudeBinary(
96
+ resolvedEnv: Record<string, string | undefined>,
97
+ fallbackEnv: Record<string, string | undefined> = process.env,
98
+ ): string {
99
+ const candidate = resolvedEnv.CLAUDE_BINARY?.trim() || fallbackEnv.CLAUDE_BINARY?.trim();
100
+ return candidate || "claude";
101
+ }
102
+
103
+ /**
104
+ * Pre-seed `~/.claude.json` so the per-project trust-dialog ("Quick safety
105
+ * check: Is this a project you trust?") doesn't block on first run.
106
+ *
107
+ * Mirrors the onboarding-skip hack in `Dockerfile.worker` (which writes
108
+ * `hasCompletedOnboarding` and `bypassPermissionsModeAccepted`). When the
109
+ * resolved binary contains "shannon", claude runs inside tmux and shannon
110
+ * does NOT auto-accept the dialog, so the pane hangs forever. Writing
111
+ * `projects[cwd].hasTrustDialogAccepted = true` (and `hasCompletedProjectOnboarding`)
112
+ * tells claude-code the cwd is pre-trusted.
113
+ *
114
+ * Idempotent (no-op when already true), read-merge-write (never clobbers
115
+ * other keys), graceful on missing / malformed file.
116
+ *
117
+ * Exported for unit testing.
118
+ */
119
+ export async function preseedClaudeTrustDialog(
120
+ cwd: string,
121
+ // Prefer `$HOME` over `homedir()` so callers in tests / sandboxed envs that
122
+ // override HOME get the override. Bun's `os.homedir()` caches the real
123
+ // passwd entry at process boot and ignores HOME mutations.
124
+ homeDir: string = process.env.HOME ?? homedir(),
125
+ ): Promise<void> {
126
+ const claudeJsonPath = join(homeDir, ".claude.json");
127
+ let data: Record<string, unknown> = {};
128
+ try {
129
+ const raw = await readFile(claudeJsonPath, "utf-8");
130
+ const parsed = JSON.parse(raw);
131
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
132
+ data = parsed as Record<string, unknown>;
133
+ }
134
+ } catch {
135
+ // missing or malformed — start from {}
136
+ }
137
+
138
+ const projects = (data.projects ?? {}) as Record<string, Record<string, unknown>>;
139
+ const existing = projects[cwd] ?? {};
140
+ if (existing.hasTrustDialogAccepted === true) {
141
+ // Already trusted — no-op, no write.
142
+ return;
143
+ }
144
+
145
+ projects[cwd] = {
146
+ ...existing,
147
+ hasTrustDialogAccepted: true,
148
+ hasCompletedProjectOnboarding: true,
149
+ };
150
+ data.projects = projects;
151
+
152
+ await writeFile(claudeJsonPath, `${JSON.stringify(data, null, 2)}\n`);
153
+ }
154
+
63
155
  /**
64
156
  * Merge a base MCP config (typically read from `.mcp.json`) with freshly-resolved
65
157
  * installed servers from the API, and inject the per-task `X-Source-Task-Id` header
@@ -178,7 +270,7 @@ class ClaudeSession implements ProviderSession {
178
270
  taskFilePath: string,
179
271
  taskFilePid: number,
180
272
  private sessionMcpConfig: string | null = null,
181
- private claudeBinary: string = "claude",
273
+ private claudeBinaryArgv: readonly string[] = ["claude"],
182
274
  ) {
183
275
  this.taskFilePid = taskFilePid;
184
276
  this.contextWindowSize = getContextWindowSize(model);
@@ -217,7 +309,7 @@ class ClaudeSession implements ProviderSession {
217
309
 
218
310
  private buildCommand(): string[] {
219
311
  const cmd = [
220
- this.claudeBinary,
312
+ ...this.claudeBinaryArgv,
221
313
  "--model",
222
314
  this.model,
223
315
  "--verbose",
@@ -518,7 +610,7 @@ class ClaudeSession implements ProviderSession {
518
610
  taskFilePath,
519
611
  this.taskFilePid,
520
612
  null,
521
- this.claudeBinary,
613
+ this.claudeBinaryArgv,
522
614
  );
523
615
 
524
616
  // Forward events from retry to our listeners
@@ -548,8 +640,47 @@ export class ClaudeAdapter implements ProviderAdapter {
548
640
  const credType = validateClaudeCredentials(config.env || process.env);
549
641
  console.log(`\x1b[2m[claude]\x1b[0m Using credential: ${credType}`);
550
642
 
551
- // Resolve claude binary: CLAUDE_BINARY env var > "claude" (PATH lookup)
552
- const claudeBinary = process.env.CLAUDE_BINARY || "claude";
643
+ // Resolve the argv prefix. Same flags (`-p`, `--model`, ...) work across
644
+ // alternates; only argv[0..n] changes. `CLAUDE_BINARY` accepts a single
645
+ // binary (`"shannon"`, `"/usr/local/bin/shannon"`) or a whitespace-separated
646
+ // command string (`"bunx @dexh/shannon"`, `"npx -y @dexh/shannon"`).
647
+ // Setting it to anything containing `shannon` opts into the dexhorthy/shannon
648
+ // variant, which drives `claude` interactively in tmux to stay on the
649
+ // subscription credit pool after the 2026-06-15 programmatic-credit split.
650
+ //
651
+ // `config.env` carries the swarm_config overlay (resolved repo > agent > global
652
+ // by `fetchResolvedEnv` in src/commands/runner.ts), so operators can flip
653
+ // a worker's binary via `set-config CLAUDE_BINARY=...` without a restart.
654
+ // Falls back to process.env, then "claude". See `resolveClaudeBinary` above.
655
+ //
656
+ // See `docs-site/.../shannon-experimental.mdx` for the user-facing guide
657
+ // and `runbooks/harness-providers.md` for engineering notes.
658
+ const claudeBinaryRaw = resolveClaudeBinary(config.env || process.env);
659
+ const claudeBinaryArgv = parseClaudeBinary(claudeBinaryRaw);
660
+ const isShannon = claudeBinaryRaw.toLowerCase().includes("shannon");
661
+
662
+ // Fail fast: shannon shells out to tmux. If it's missing, surface a
663
+ // clear error here rather than letting the spawn fail opaquely.
664
+ if (isShannon && !Bun.which("tmux")) {
665
+ throw new Error(
666
+ "CLAUDE_BINARY=shannon requires 'tmux' on PATH (install via apt/brew). See runbooks/harness-providers.md.",
667
+ );
668
+ }
669
+
670
+ // Shannon drives `claude` in tmux — claude's per-project trust dialog
671
+ // (first-run "Is this a project you trust?") hangs the pane because shannon
672
+ // doesn't auto-accept it. Pre-seed `~/.claude.json` so the dialog never
673
+ // prompts. Idempotent; no-op when already trusted. Engineering rationale:
674
+ // `runbooks/harness-providers.md` § "Trust-dialog pre-seed".
675
+ if (isShannon) {
676
+ try {
677
+ await preseedClaudeTrustDialog(config.cwd);
678
+ } catch (err) {
679
+ console.warn(
680
+ `\x1b[33m[claude]\x1b[0m Failed to pre-seed trust dialog for ${config.cwd}: ${err}`,
681
+ );
682
+ }
683
+ }
553
684
 
554
685
  const taskFilePid = process.pid;
555
686
  const taskFilePath = await writeTaskFile(taskFilePid, {
@@ -584,7 +715,7 @@ export class ClaudeAdapter implements ProviderAdapter {
584
715
  taskFilePath,
585
716
  taskFilePid,
586
717
  sessionMcpConfig,
587
- claudeBinary,
718
+ claudeBinaryArgv,
588
719
  );
589
720
  }
590
721
 
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Tests for the `CLAUDE_BINARY` env override + trust pre-seed in
3
+ * `ClaudeAdapter.createSession` and the shared helpers.
4
+ *
5
+ * Behaviors under test:
6
+ * 1. Binary resolution — argv[0..n] tracks `parseClaudeBinary(process.env.CLAUDE_BINARY)`,
7
+ * with `["claude"]` as the default. Same flags follow. Supports
8
+ * whitespace-separated command strings (e.g. `"bunx @dexh/shannon"`).
9
+ * 2. Tmux fail-fast — when the resolved binary string contains "shannon"
10
+ * (anywhere — including inside a command string), createSession throws
11
+ * if `tmux` is not on PATH.
12
+ * 3. Trust pre-seed — when the resolved binary contains "shannon", the
13
+ * adapter writes `projects[cwd].hasTrustDialogAccepted: true` to
14
+ * `$HOME/.claude.json` before spawning. Idempotent. No-op for "claude".
15
+ *
16
+ * `Bun.spawn` is stubbed so the tests don't actually exec anything; we read
17
+ * the argv off the call args. `Bun.which` is stubbed for the tmux gate so
18
+ * the tests don't depend on the host having tmux installed. `$HOME` is
19
+ * redirected to a tmp dir so the trust-preseed never touches the real
20
+ * `~/.claude.json`.
21
+ */
22
+
23
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
24
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import {
28
+ ClaudeAdapter,
29
+ parseClaudeBinary,
30
+ preseedClaudeTrustDialog,
31
+ resolveClaudeBinary,
32
+ } from "../providers/claude-adapter";
33
+ import type { ProviderSessionConfig } from "../providers/types";
34
+
35
+ /** Minimal config — empty apiUrl/apiKey/agentId skips the MCP-server fetch. */
36
+ function makeConfig(overrides: Partial<ProviderSessionConfig> = {}): ProviderSessionConfig {
37
+ return {
38
+ prompt: "Say hello",
39
+ systemPrompt: "",
40
+ model: "sonnet",
41
+ role: "worker",
42
+ agentId: "",
43
+ taskId: "test-task-binary",
44
+ apiUrl: "",
45
+ apiKey: "",
46
+ cwd: "/tmp",
47
+ logFile: "/tmp/test-claude-adapter-binary.jsonl",
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ /** Fake Bun.Subprocess that behaves as a process that exited cleanly with no output. */
53
+ function makeFakeProc(): ReturnType<typeof Bun.spawn> {
54
+ return {
55
+ stdout: null,
56
+ stderr: null,
57
+ stdin: null,
58
+ exited: Promise.resolve(0),
59
+ exitCode: 0,
60
+ kill: () => {},
61
+ pid: 0,
62
+ killed: false,
63
+ ref: () => {},
64
+ unref: () => {},
65
+ } as unknown as ReturnType<typeof Bun.spawn>;
66
+ }
67
+
68
+ // ─── Pure-function tests ──────────────────────────────────────────────────────
69
+
70
+ describe("parseClaudeBinary", () => {
71
+ test("undefined → ['claude']", () => {
72
+ expect(parseClaudeBinary(undefined)).toEqual(["claude"]);
73
+ });
74
+
75
+ test("empty string → ['claude']", () => {
76
+ expect(parseClaudeBinary("")).toEqual(["claude"]);
77
+ expect(parseClaudeBinary(" ")).toEqual(["claude"]);
78
+ });
79
+
80
+ test("single token → one-element array", () => {
81
+ expect(parseClaudeBinary("claude")).toEqual(["claude"]);
82
+ expect(parseClaudeBinary("shannon")).toEqual(["shannon"]);
83
+ expect(parseClaudeBinary("/usr/local/bin/shannon")).toEqual(["/usr/local/bin/shannon"]);
84
+ });
85
+
86
+ test("command string → whitespace-split argv", () => {
87
+ expect(parseClaudeBinary("bunx @dexh/shannon")).toEqual(["bunx", "@dexh/shannon"]);
88
+ expect(parseClaudeBinary("npx -y @dexh/shannon")).toEqual(["npx", "-y", "@dexh/shannon"]);
89
+ });
90
+
91
+ test("version-pinned → preserves the version suffix", () => {
92
+ expect(parseClaudeBinary("bunx @dexh/shannon@1.2.3")).toEqual(["bunx", "@dexh/shannon@1.2.3"]);
93
+ });
94
+
95
+ test("multiple-space tolerance → trims + collapses", () => {
96
+ expect(parseClaudeBinary(" bunx shannon ")).toEqual(["bunx", "shannon"]);
97
+ expect(parseClaudeBinary("\tbunx\t@dexh/shannon\n")).toEqual(["bunx", "@dexh/shannon"]);
98
+ });
99
+ });
100
+
101
+ describe("resolveClaudeBinary precedence", () => {
102
+ test("resolvedEnv wins over fallbackEnv (swarm_config overrides process.env)", () => {
103
+ const resolvedEnv = { CLAUDE_BINARY: "shannon" };
104
+ const fallbackEnv = { CLAUDE_BINARY: "claude" };
105
+ expect(resolveClaudeBinary(resolvedEnv, fallbackEnv)).toBe("shannon");
106
+ });
107
+
108
+ test("falls back to fallbackEnv when resolvedEnv is absent", () => {
109
+ const resolvedEnv = {};
110
+ const fallbackEnv = { CLAUDE_BINARY: "bunx @dexh/shannon" };
111
+ expect(resolveClaudeBinary(resolvedEnv, fallbackEnv)).toBe("bunx @dexh/shannon");
112
+ });
113
+
114
+ test("both absent → 'claude' default", () => {
115
+ expect(resolveClaudeBinary({}, {})).toBe("claude");
116
+ });
117
+
118
+ test("empty / whitespace-only resolvedEnv value falls through to fallbackEnv", () => {
119
+ // `.trim() || …` falls through on empty/whitespace.
120
+ expect(resolveClaudeBinary({ CLAUDE_BINARY: "" }, { CLAUDE_BINARY: "shannon" })).toBe(
121
+ "shannon",
122
+ );
123
+ expect(resolveClaudeBinary({ CLAUDE_BINARY: " " }, { CLAUDE_BINARY: "shannon" })).toBe(
124
+ "shannon",
125
+ );
126
+ });
127
+
128
+ test("empty fallback after empty resolved → 'claude' default", () => {
129
+ expect(resolveClaudeBinary({ CLAUDE_BINARY: "" }, { CLAUDE_BINARY: "" })).toBe("claude");
130
+ });
131
+
132
+ test("command-string passes through unchanged (caller does the argv split)", () => {
133
+ const resolvedEnv = { CLAUDE_BINARY: "bunx @dexh/shannon@1.2.3" };
134
+ expect(resolveClaudeBinary(resolvedEnv, {})).toBe("bunx @dexh/shannon@1.2.3");
135
+ });
136
+
137
+ test("fallbackEnv defaults to process.env when omitted", () => {
138
+ // Smoke-test the default arg. Set + read process.env directly.
139
+ const orig = process.env.CLAUDE_BINARY;
140
+ process.env.CLAUDE_BINARY = "test-default-arg";
141
+ try {
142
+ expect(resolveClaudeBinary({})).toBe("test-default-arg");
143
+ } finally {
144
+ if (orig === undefined) {
145
+ delete process.env.CLAUDE_BINARY;
146
+ } else {
147
+ process.env.CLAUDE_BINARY = orig;
148
+ }
149
+ }
150
+ });
151
+ });
152
+
153
+ describe("preseedClaudeTrustDialog", () => {
154
+ let homeDir: string;
155
+
156
+ beforeEach(async () => {
157
+ homeDir = await mkdtemp(join(tmpdir(), "claude-trust-test-"));
158
+ });
159
+
160
+ afterEach(async () => {
161
+ await rm(homeDir, { recursive: true, force: true });
162
+ });
163
+
164
+ test("creates ~/.claude.json with the cwd trusted when file is missing", async () => {
165
+ const cwd = "/abs/cwd/x";
166
+ await preseedClaudeTrustDialog(cwd, homeDir);
167
+
168
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
169
+ expect(data.projects[cwd].hasTrustDialogAccepted).toBe(true);
170
+ expect(data.projects[cwd].hasCompletedProjectOnboarding).toBe(true);
171
+ });
172
+
173
+ test("preserves existing top-level keys (read-merge-write, no clobber)", async () => {
174
+ await writeFile(
175
+ join(homeDir, ".claude.json"),
176
+ JSON.stringify({
177
+ hasCompletedOnboarding: true,
178
+ bypassPermissionsModeAccepted: true,
179
+ unrelated: "value",
180
+ }),
181
+ );
182
+ await preseedClaudeTrustDialog("/abs/cwd/x", homeDir);
183
+
184
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
185
+ expect(data.hasCompletedOnboarding).toBe(true);
186
+ expect(data.bypassPermissionsModeAccepted).toBe(true);
187
+ expect(data.unrelated).toBe("value");
188
+ expect(data.projects["/abs/cwd/x"].hasTrustDialogAccepted).toBe(true);
189
+ });
190
+
191
+ test("preserves other projects' entries", async () => {
192
+ await writeFile(
193
+ join(homeDir, ".claude.json"),
194
+ JSON.stringify({
195
+ projects: {
196
+ "/other/project": {
197
+ hasTrustDialogAccepted: true,
198
+ customKey: 42,
199
+ },
200
+ },
201
+ }),
202
+ );
203
+ await preseedClaudeTrustDialog("/abs/cwd/x", homeDir);
204
+
205
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
206
+ expect(data.projects["/other/project"]).toEqual({
207
+ hasTrustDialogAccepted: true,
208
+ customKey: 42,
209
+ });
210
+ expect(data.projects["/abs/cwd/x"].hasTrustDialogAccepted).toBe(true);
211
+ });
212
+
213
+ test("idempotent: already-trusted cwd is a no-op (file not rewritten)", async () => {
214
+ await writeFile(
215
+ join(homeDir, ".claude.json"),
216
+ JSON.stringify({
217
+ projects: {
218
+ "/abs/cwd/x": { hasTrustDialogAccepted: true, customKey: "preserved" },
219
+ },
220
+ }),
221
+ );
222
+ const beforeStat = await Bun.file(join(homeDir, ".claude.json")).text();
223
+ await preseedClaudeTrustDialog("/abs/cwd/x", homeDir);
224
+ const afterStat = await Bun.file(join(homeDir, ".claude.json")).text();
225
+
226
+ // No-op → file contents unchanged.
227
+ expect(afterStat).toBe(beforeStat);
228
+ });
229
+
230
+ test("malformed file: starts from {} and writes the entry", async () => {
231
+ await writeFile(join(homeDir, ".claude.json"), "{ this is not valid json");
232
+ await preseedClaudeTrustDialog("/abs/cwd/x", homeDir);
233
+
234
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
235
+ expect(data.projects["/abs/cwd/x"].hasTrustDialogAccepted).toBe(true);
236
+ });
237
+ });
238
+
239
+ // ─── Integration tests through ClaudeAdapter.createSession ────────────────────
240
+
241
+ describe("CLAUDE_BINARY env override", () => {
242
+ // Cache the originals and restore after each test so the suite stays clean.
243
+ let originalClaudeBinary: string | undefined;
244
+ let originalOauthToken: string | undefined;
245
+ let originalHome: string | undefined;
246
+ let homeDir: string;
247
+ let spawnSpy: ReturnType<typeof spyOn>;
248
+ let whichSpy: ReturnType<typeof spyOn>;
249
+ let spawnedArgs: Array<readonly string[]>;
250
+
251
+ beforeEach(async () => {
252
+ originalClaudeBinary = process.env.CLAUDE_BINARY;
253
+ originalOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
254
+ originalHome = process.env.HOME;
255
+ homeDir = await mkdtemp(join(tmpdir(), "claude-adapter-test-home-"));
256
+ process.env.HOME = homeDir;
257
+ delete process.env.CLAUDE_BINARY;
258
+ // Credential check runs before binary resolution; satisfy it.
259
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "test-token";
260
+
261
+ spawnedArgs = [];
262
+ spawnSpy = spyOn(Bun, "spawn").mockImplementation(((cmd: readonly string[]) => {
263
+ spawnedArgs.push(cmd);
264
+ return makeFakeProc();
265
+ }) as typeof Bun.spawn);
266
+
267
+ // Default: pretend tmux IS on PATH so non-tmux-gate tests don't trip.
268
+ whichSpy = spyOn(Bun, "which").mockImplementation((name: string) => {
269
+ if (name === "tmux") return "/usr/bin/tmux";
270
+ return null;
271
+ });
272
+ });
273
+
274
+ afterEach(async () => {
275
+ spawnSpy.mockRestore();
276
+ whichSpy.mockRestore();
277
+ await rm(homeDir, { recursive: true, force: true });
278
+ if (originalHome === undefined) {
279
+ delete process.env.HOME;
280
+ } else {
281
+ process.env.HOME = originalHome;
282
+ }
283
+ if (originalClaudeBinary === undefined) {
284
+ delete process.env.CLAUDE_BINARY;
285
+ } else {
286
+ process.env.CLAUDE_BINARY = originalClaudeBinary;
287
+ }
288
+ if (originalOauthToken === undefined) {
289
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
290
+ } else {
291
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOauthToken;
292
+ }
293
+ });
294
+
295
+ test("default: argv[0] is 'claude' when CLAUDE_BINARY is unset", async () => {
296
+ const adapter = new ClaudeAdapter();
297
+ await adapter.createSession(makeConfig());
298
+
299
+ expect(spawnedArgs).toHaveLength(1);
300
+ const argv = spawnedArgs[0];
301
+ expect(argv[0]).toBe("claude");
302
+ });
303
+
304
+ test("override: argv[0] is 'shannon' when CLAUDE_BINARY=shannon", async () => {
305
+ process.env.CLAUDE_BINARY = "shannon";
306
+
307
+ const adapter = new ClaudeAdapter();
308
+ await adapter.createSession(makeConfig());
309
+
310
+ const argv = spawnedArgs[0];
311
+ expect(argv[0]).toBe("shannon");
312
+ });
313
+
314
+ test("custom path: argv[0] is the absolute path when CLAUDE_BINARY=/usr/local/bin/shannon", async () => {
315
+ process.env.CLAUDE_BINARY = "/usr/local/bin/shannon";
316
+
317
+ const adapter = new ClaudeAdapter();
318
+ await adapter.createSession(makeConfig());
319
+
320
+ expect(spawnedArgs[0][0]).toBe("/usr/local/bin/shannon");
321
+ });
322
+
323
+ test("command string: 'bunx @dexh/shannon' → argv[0..1] is ['bunx', '@dexh/shannon']", async () => {
324
+ process.env.CLAUDE_BINARY = "bunx @dexh/shannon";
325
+
326
+ const adapter = new ClaudeAdapter();
327
+ await adapter.createSession(makeConfig());
328
+
329
+ const argv = spawnedArgs[0];
330
+ expect(argv[0]).toBe("bunx");
331
+ expect(argv[1]).toBe("@dexh/shannon");
332
+ // Claude args follow.
333
+ expect(argv).toContain("--model");
334
+ expect(argv).toContain("-p");
335
+ });
336
+
337
+ test("version-pinned command string: argv[0..1] = ['bunx', '@dexh/shannon@1.2.3']", async () => {
338
+ process.env.CLAUDE_BINARY = "bunx @dexh/shannon@1.2.3";
339
+
340
+ const adapter = new ClaudeAdapter();
341
+ await adapter.createSession(makeConfig());
342
+
343
+ const argv = spawnedArgs[0];
344
+ expect(argv[0]).toBe("bunx");
345
+ expect(argv[1]).toBe("@dexh/shannon@1.2.3");
346
+ });
347
+
348
+ test("multiple-space tolerance: ' bunx shannon ' → argv = ['bunx', 'shannon', ...]", async () => {
349
+ process.env.CLAUDE_BINARY = " bunx shannon ";
350
+
351
+ const adapter = new ClaudeAdapter();
352
+ await adapter.createSession(makeConfig());
353
+
354
+ const argv = spawnedArgs[0];
355
+ expect(argv[0]).toBe("bunx");
356
+ expect(argv[1]).toBe("shannon");
357
+ expect(argv).toContain("--model");
358
+ });
359
+
360
+ test("argv[1..] (after prefix) matches between default 'claude' and command-string 'bunx @dexh/shannon'", async () => {
361
+ process.env.CLAUDE_BINARY = "bunx @dexh/shannon";
362
+ const adapter = new ClaudeAdapter();
363
+ await adapter.createSession(makeConfig());
364
+ // Drop the 2-element prefix.
365
+ const argvShannon = spawnedArgs[0].slice(2);
366
+
367
+ spawnedArgs = [];
368
+ delete process.env.CLAUDE_BINARY;
369
+ await adapter.createSession(makeConfig());
370
+ // Drop the 1-element prefix.
371
+ const argvClaude = spawnedArgs[0].slice(1);
372
+
373
+ expect(argvShannon).toEqual(argvClaude);
374
+ });
375
+
376
+ test("swarm_config overlay (config.env) wins over process.env CLAUDE_BINARY", async () => {
377
+ // process.env says "claude" — but the runner's resolvedEnv overlay (passed
378
+ // through config.env) says "shannon". The overlay must win, mirroring the
379
+ // HARNESS_PROVIDER reload path.
380
+ process.env.CLAUDE_BINARY = "claude";
381
+
382
+ const adapter = new ClaudeAdapter();
383
+ await adapter.createSession(
384
+ makeConfig({
385
+ env: { CLAUDE_BINARY: "shannon", CLAUDE_CODE_OAUTH_TOKEN: "test-token" } as Record<
386
+ string,
387
+ string
388
+ >,
389
+ }),
390
+ );
391
+
392
+ expect(spawnedArgs[0][0]).toBe("shannon");
393
+ });
394
+
395
+ test("config.env CLAUDE_BINARY='bunx @dexh/shannon' (swarm_config override) splits + spawns correctly", async () => {
396
+ delete process.env.CLAUDE_BINARY;
397
+
398
+ const adapter = new ClaudeAdapter();
399
+ await adapter.createSession(
400
+ makeConfig({
401
+ env: {
402
+ CLAUDE_BINARY: "bunx @dexh/shannon",
403
+ CLAUDE_CODE_OAUTH_TOKEN: "test-token",
404
+ } as Record<string, string>,
405
+ }),
406
+ );
407
+
408
+ expect(spawnedArgs[0][0]).toBe("bunx");
409
+ expect(spawnedArgs[0][1]).toBe("@dexh/shannon");
410
+ });
411
+
412
+ test("config.env without CLAUDE_BINARY falls back to process.env", async () => {
413
+ process.env.CLAUDE_BINARY = "shannon";
414
+
415
+ const adapter = new ClaudeAdapter();
416
+ await adapter.createSession(
417
+ makeConfig({
418
+ // env has CLAUDE_CODE_OAUTH_TOKEN but no CLAUDE_BINARY → process.env wins.
419
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "test-token" } as Record<string, string>,
420
+ }),
421
+ );
422
+
423
+ expect(spawnedArgs[0][0]).toBe("shannon");
424
+ });
425
+ });
426
+
427
+ describe("Shannon tmux fail-fast gate", () => {
428
+ let originalClaudeBinary: string | undefined;
429
+ let originalOauthToken: string | undefined;
430
+ let originalHome: string | undefined;
431
+ let homeDir: string;
432
+ let spawnSpy: ReturnType<typeof spyOn>;
433
+ let whichSpy: ReturnType<typeof spyOn>;
434
+
435
+ beforeEach(async () => {
436
+ originalClaudeBinary = process.env.CLAUDE_BINARY;
437
+ originalOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
438
+ originalHome = process.env.HOME;
439
+ homeDir = await mkdtemp(join(tmpdir(), "claude-adapter-test-home-"));
440
+ process.env.HOME = homeDir;
441
+ delete process.env.CLAUDE_BINARY;
442
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "test-token";
443
+ spawnSpy = spyOn(Bun, "spawn").mockImplementation((() => makeFakeProc()) as typeof Bun.spawn);
444
+ whichSpy = spyOn(Bun, "which");
445
+ });
446
+
447
+ afterEach(async () => {
448
+ spawnSpy.mockRestore();
449
+ whichSpy.mockRestore();
450
+ await rm(homeDir, { recursive: true, force: true });
451
+ if (originalHome === undefined) {
452
+ delete process.env.HOME;
453
+ } else {
454
+ process.env.HOME = originalHome;
455
+ }
456
+ if (originalClaudeBinary === undefined) {
457
+ delete process.env.CLAUDE_BINARY;
458
+ } else {
459
+ process.env.CLAUDE_BINARY = originalClaudeBinary;
460
+ }
461
+ if (originalOauthToken === undefined) {
462
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
463
+ } else {
464
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOauthToken;
465
+ }
466
+ });
467
+
468
+ test("sad path: rejects with tmux-mentioning error when CLAUDE_BINARY=shannon and tmux is missing", async () => {
469
+ process.env.CLAUDE_BINARY = "shannon";
470
+ whichSpy.mockImplementation((name: string) => {
471
+ if (name === "tmux") return null;
472
+ return `/usr/bin/${name}`;
473
+ });
474
+
475
+ const adapter = new ClaudeAdapter();
476
+ await expect(adapter.createSession(makeConfig())).rejects.toThrow(/tmux/i);
477
+ });
478
+
479
+ test("happy path: does not throw when CLAUDE_BINARY=shannon and tmux IS on PATH", async () => {
480
+ process.env.CLAUDE_BINARY = "shannon";
481
+ whichSpy.mockImplementation((name: string) => {
482
+ if (name === "tmux") return "/usr/bin/tmux";
483
+ return null;
484
+ });
485
+
486
+ const adapter = new ClaudeAdapter();
487
+ await expect(adapter.createSession(makeConfig())).resolves.toBeDefined();
488
+ });
489
+
490
+ test("non-shannon binary skips the tmux check (no Bun.which call for tmux)", async () => {
491
+ process.env.CLAUDE_BINARY = "claude";
492
+ whichSpy.mockImplementation((name: string) => {
493
+ if (name === "tmux") return null;
494
+ return null;
495
+ });
496
+
497
+ const adapter = new ClaudeAdapter();
498
+ // Should NOT throw even though tmux is "missing".
499
+ await expect(adapter.createSession(makeConfig())).resolves.toBeDefined();
500
+ });
501
+
502
+ test("custom shannon path (e.g. /usr/local/bin/shannon) still triggers the tmux check", async () => {
503
+ process.env.CLAUDE_BINARY = "/usr/local/bin/shannon";
504
+ whichSpy.mockImplementation((name: string) => {
505
+ if (name === "tmux") return null;
506
+ return null;
507
+ });
508
+
509
+ const adapter = new ClaudeAdapter();
510
+ await expect(adapter.createSession(makeConfig())).rejects.toThrow(/tmux/i);
511
+ });
512
+
513
+ test("command-string CLAUDE_BINARY='bunx @dexh/shannon' still triggers the tmux check", async () => {
514
+ process.env.CLAUDE_BINARY = "bunx @dexh/shannon";
515
+ whichSpy.mockImplementation((name: string) => {
516
+ if (name === "tmux") return null;
517
+ return null;
518
+ });
519
+
520
+ const adapter = new ClaudeAdapter();
521
+ await expect(adapter.createSession(makeConfig())).rejects.toThrow(/tmux/i);
522
+ });
523
+ });
524
+
525
+ describe("Trust pre-seed via ClaudeAdapter.createSession", () => {
526
+ let originalClaudeBinary: string | undefined;
527
+ let originalOauthToken: string | undefined;
528
+ let originalHome: string | undefined;
529
+ let homeDir: string;
530
+ let spawnSpy: ReturnType<typeof spyOn>;
531
+ let whichSpy: ReturnType<typeof spyOn>;
532
+
533
+ beforeEach(async () => {
534
+ originalClaudeBinary = process.env.CLAUDE_BINARY;
535
+ originalOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
536
+ originalHome = process.env.HOME;
537
+ homeDir = await mkdtemp(join(tmpdir(), "claude-adapter-trust-test-"));
538
+ process.env.HOME = homeDir;
539
+ delete process.env.CLAUDE_BINARY;
540
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "test-token";
541
+ spawnSpy = spyOn(Bun, "spawn").mockImplementation((() => makeFakeProc()) as typeof Bun.spawn);
542
+ whichSpy = spyOn(Bun, "which").mockImplementation((name: string) => {
543
+ if (name === "tmux") return "/usr/bin/tmux";
544
+ return null;
545
+ });
546
+ });
547
+
548
+ afterEach(async () => {
549
+ spawnSpy.mockRestore();
550
+ whichSpy.mockRestore();
551
+ await rm(homeDir, { recursive: true, force: true });
552
+ if (originalHome === undefined) {
553
+ delete process.env.HOME;
554
+ } else {
555
+ process.env.HOME = originalHome;
556
+ }
557
+ if (originalClaudeBinary === undefined) {
558
+ delete process.env.CLAUDE_BINARY;
559
+ } else {
560
+ process.env.CLAUDE_BINARY = originalClaudeBinary;
561
+ }
562
+ if (originalOauthToken === undefined) {
563
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
564
+ } else {
565
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOauthToken;
566
+ }
567
+ });
568
+
569
+ test("CLAUDE_BINARY=shannon writes hasTrustDialogAccepted for config.cwd", async () => {
570
+ process.env.CLAUDE_BINARY = "shannon";
571
+ const cwd = "/some/abs/cwd";
572
+ const adapter = new ClaudeAdapter();
573
+ await adapter.createSession(makeConfig({ cwd }));
574
+
575
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
576
+ expect(data.projects[cwd].hasTrustDialogAccepted).toBe(true);
577
+ expect(data.projects[cwd].hasCompletedProjectOnboarding).toBe(true);
578
+ });
579
+
580
+ test("CLAUDE_BINARY='bunx @dexh/shannon' (command string) also triggers the pre-seed", async () => {
581
+ process.env.CLAUDE_BINARY = "bunx @dexh/shannon";
582
+ const cwd = "/some/other/cwd";
583
+ const adapter = new ClaudeAdapter();
584
+ await adapter.createSession(makeConfig({ cwd }));
585
+
586
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
587
+ expect(data.projects[cwd].hasTrustDialogAccepted).toBe(true);
588
+ });
589
+
590
+ test("idempotent: re-creating session with shannon does not rewrite the file", async () => {
591
+ process.env.CLAUDE_BINARY = "shannon";
592
+ const cwd = "/some/abs/cwd";
593
+ const adapter = new ClaudeAdapter();
594
+ await adapter.createSession(makeConfig({ cwd }));
595
+ const first = await readFile(join(homeDir, ".claude.json"), "utf-8");
596
+ await adapter.createSession(makeConfig({ cwd }));
597
+ const second = await readFile(join(homeDir, ".claude.json"), "utf-8");
598
+ expect(second).toBe(first);
599
+ });
600
+
601
+ test("preserves other projects' entries when seeding a new cwd", async () => {
602
+ await writeFile(
603
+ join(homeDir, ".claude.json"),
604
+ JSON.stringify({
605
+ projects: {
606
+ "/other/cwd": { hasTrustDialogAccepted: true, custom: 1 },
607
+ },
608
+ }),
609
+ );
610
+ process.env.CLAUDE_BINARY = "shannon";
611
+ const adapter = new ClaudeAdapter();
612
+ await adapter.createSession(makeConfig({ cwd: "/new/cwd" }));
613
+
614
+ const data = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"));
615
+ expect(data.projects["/other/cwd"]).toEqual({ hasTrustDialogAccepted: true, custom: 1 });
616
+ expect(data.projects["/new/cwd"].hasTrustDialogAccepted).toBe(true);
617
+ });
618
+
619
+ test("default CLAUDE_BINARY=claude does NOT touch ~/.claude.json", async () => {
620
+ delete process.env.CLAUDE_BINARY;
621
+ const adapter = new ClaudeAdapter();
622
+ await adapter.createSession(makeConfig({ cwd: "/some/abs/cwd" }));
623
+
624
+ // No .claude.json should have been written.
625
+ const exists = await Bun.file(join(homeDir, ".claude.json")).exists();
626
+ expect(exists).toBe(false);
627
+ });
628
+ });
@@ -83,7 +83,16 @@ describe("GET /p/:id — HTML public path", () => {
83
83
  const res = await fetch(`${BASE}/p/${id}`);
84
84
  expect(res.status).toBe(200);
85
85
  expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html");
86
- expect(res.headers.get("content-security-policy")).toBeTruthy();
86
+ const csp = res.headers.get("content-security-policy");
87
+ expect(csp).toBeTruthy();
88
+ // jsdelivr + unpkg are allowlisted so pages can <script src="…"> common
89
+ // viz libs (Chart.js, ApexCharts, D3, …) instead of inlining bundles.
90
+ const scriptSrc = csp?.split(";").find((d) => d.trim().startsWith("script-src ")) ?? "";
91
+ expect(scriptSrc).toContain("https://cdn.jsdelivr.net");
92
+ expect(scriptSrc).toContain("https://unpkg.com");
93
+ const styleSrc = csp?.split(";").find((d) => d.trim().startsWith("style-src ")) ?? "";
94
+ expect(styleSrc).toContain("https://cdn.jsdelivr.net");
95
+ expect(styleSrc).toContain("https://unpkg.com");
87
96
  const text = await res.text();
88
97
  expect(text).toContain("<h1>Hello</h1>");
89
98
  expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
@@ -84,14 +84,11 @@ async function defaultSpawnClaudeCli(
84
84
  signal?: AbortSignal,
85
85
  jsonSchema?: object,
86
86
  ): Promise<string> {
87
- const cmd = [
88
- process.env.CLAUDE_BINARY ?? "claude",
89
- "-p",
90
- "--model",
91
- model,
92
- "--output-format",
93
- "json",
94
- ];
87
+ // CLAUDE_BINARY may be a single binary ("claude", "shannon") or a
88
+ // whitespace-separated command string ("bunx @dexh/shannon"). See
89
+ // parseClaudeBinary in src/providers/claude-adapter.ts.
90
+ const claudeBinaryArgv = (process.env.CLAUDE_BINARY ?? "claude").trim().split(/\s+/);
91
+ const cmd = [...claudeBinaryArgv, "-p", "--model", model, "--output-format", "json"];
95
92
  if (jsonSchema) {
96
93
  cmd.push("--json-schema", JSON.stringify(jsonSchema));
97
94
  }