@desplega.ai/agent-swarm 1.79.0 → 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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
@@ -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
+ });
@@ -1,10 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ agentContextKey,
3
4
  agentmailContextKey,
4
5
  buildJiraContextKey,
5
6
  githubContextKey,
6
7
  gitlabContextKey,
7
8
  linearContextKey,
9
+ pageContextKey,
8
10
  parseContextKey,
9
11
  scheduleContextKey,
10
12
  slackContextKey,
@@ -49,6 +51,21 @@ describe("context-key builders", () => {
49
51
  test("workflowContextKey builds expected format", () => {
50
52
  expect(workflowContextKey({ workflowRunId: "run-uuid" })).toBe("task:workflow:run-uuid");
51
53
  });
54
+
55
+ test("agentContextKey builds expected format", () => {
56
+ expect(agentContextKey({ agentId: "agent-uuid" })).toBe("task:agent:agent-uuid");
57
+ });
58
+
59
+ test("pageContextKey builds expected format", () => {
60
+ expect(pageContextKey({ pageId: "page-uuid" })).toBe("task:page:page-uuid");
61
+ });
62
+
63
+ test("agentContextKey and pageContextKey reject empty / colon-bearing inputs", () => {
64
+ expect(() => agentContextKey({ agentId: "" })).toThrow(/non-empty/);
65
+ expect(() => agentContextKey({ agentId: "a:b" })).toThrow(/must not contain/);
66
+ expect(() => pageContextKey({ pageId: "" })).toThrow(/non-empty/);
67
+ expect(() => pageContextKey({ pageId: "p:q" })).toThrow(/must not contain/);
68
+ });
52
69
  });
53
70
 
54
71
  describe("context-key separator safety", () => {