@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
@@ -22,8 +22,8 @@
22
22
  */
23
23
  import type { IncomingMessage, ServerResponse } from "node:http";
24
24
  import { z } from "zod";
25
- import { BROWSER_SDK_JS } from "../artifact-sdk/browser-sdk";
26
- import { getPage } from "../be/db";
25
+ import { BROWSER_SDK_JS, SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
26
+ import { getPage, incrementPageViewCount } from "../be/db";
27
27
  import type { Page } from "../types";
28
28
  import { extractAndVerifyCookie, issuePageSessionCookie } from "../utils/page-session";
29
29
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -113,10 +113,31 @@ const PAGE_HEAD_DEFAULTS = `<base target="_blank">
113
113
  code, pre, kbd, samp { font-family: "Space Mono", ui-monospace, monospace; }
114
114
  a { color: var(--swarm-primary); }
115
115
  ::selection { background: var(--swarm-primary); color: #fff; }
116
+ @media print {
117
+ /* Override theme variables so built-in primitives (swarm-diff, swarm-card)
118
+ that read var(--swarm-card) / var(--swarm-border) etc. inline-styled
119
+ backgrounds also flip to light. Without this, diff cards stay dark navy
120
+ on print. */
121
+ :root {
122
+ --swarm-bg: #ffffff;
123
+ --swarm-card: #ffffff;
124
+ --swarm-border: #cccccc;
125
+ --swarm-text: #000000;
126
+ --swarm-muted: #555555;
127
+ }
128
+ html, body { background: white !important; color: black !important; }
129
+ a { color: black !important; text-decoration: underline; }
130
+ /* Hide any swarm chrome the agent (or built-in primitives) tagged with
131
+ .no-print. Use this class on annotation badges, jump-list nav, anything
132
+ that shouldn't appear in the PDF export. */
133
+ .no-print { display: none !important; }
134
+ /* Avoid page-break inside cards / diff blocks. */
135
+ .swarm-card, swarm-diff { break-inside: avoid; }
136
+ }
116
137
  </style>`;
117
138
 
118
139
  function injectBrowserSdk(html: string): string {
119
- const injection = `${PAGE_HEAD_DEFAULTS}<script>${BROWSER_SDK_JS}</script>`;
140
+ const injection = `${PAGE_HEAD_DEFAULTS}<script>${BROWSER_SDK_JS}</script><script>${SWARM_UI_JS}</script>`;
120
141
  // Use the first occurrence of `<head>` (case-insensitive). A page that
121
142
  // doesn't have a `<head>` element (raw fragment) still gets the SDK at the
122
143
  // front of the document.
@@ -181,11 +202,14 @@ function buildCsp(): string {
181
202
  // Fonts (`fonts.googleapis.com` stylesheets + `fonts.gstatic.com` font
182
203
  // files) for the swarm default typography, and same-origin /@swarm/api/*
183
204
  // for the Browser SDK. Inline scripts/styles remain allowed so
184
- // 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.
185
209
  return [
186
210
  "default-src 'self'",
187
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com",
188
- "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",
189
213
  "font-src 'self' https://fonts.gstatic.com data:",
190
214
  "img-src 'self' data: https:",
191
215
  "connect-src 'self'",
@@ -438,6 +462,7 @@ export async function handlePagesPublic(
438
462
  body: page.body,
439
463
  }),
440
464
  );
465
+ bumpViewCount(page.id);
441
466
  return true;
442
467
  }
443
468
 
@@ -447,6 +472,9 @@ export async function handlePagesPublic(
447
472
  if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
448
473
  res.writeHead(302, headers);
449
474
  res.end();
475
+ // 302 redirects are intentionally NOT counted — they're a stop-over for
476
+ // JSON pages, and the SPA's subsequent `/p/:id.json` fetch bumps the
477
+ // counter via the JSON path above. Counting both would double-count.
450
478
  return true;
451
479
  }
452
480
 
@@ -462,5 +490,21 @@ export async function handlePagesPublic(
462
490
  if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
463
491
  res.writeHead(200, headers);
464
492
  res.end(html);
493
+ bumpViewCount(page.id);
465
494
  return true;
466
495
  }
496
+
497
+ /**
498
+ * Best-effort view-count bump. Wrapped in try/catch so a counter write never
499
+ * fails the response — pages are served before the bump runs, and any DB
500
+ * error is swallowed silently. No dedup by viewer; one bump per successful
501
+ * 200 (HTML inline or JSON metadata fetch). 302/401/403/404 responses do
502
+ * NOT bump.
503
+ */
504
+ function bumpViewCount(pageId: string): void {
505
+ try {
506
+ incrementPageViewCount(pageId);
507
+ } catch {
508
+ // intentional empty — analytics must never break page serving.
509
+ }
510
+ }
@@ -9,7 +9,7 @@
9
9
  * `cred_status` column. This file only reads those rows.
10
10
  * - This is critical for the bun-compiled API binary: importing any
11
11
  * provider-adapter code at module level drags worker-harness SDKs (e.g.
12
- * `@mariozechner/pi-coding-agent`) into the bundle, which crashes at
12
+ * `@earendil-works/pi-coding-agent`) into the bundle, which crashes at
13
13
  * `/usr/local/bin/` on boot. Keep this file adapter-free.
14
14
  * - Setup checks beyond credentials are still env- and DB-only (zero
15
15
  * network, zero side effects).
@@ -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
 
@@ -8,20 +8,20 @@
8
8
 
9
9
  import { existsSync, lstatSync, symlinkSync, unlinkSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { getModel } from "@mariozechner/pi-ai";
11
+ import { getModel } from "@earendil-works/pi-ai";
12
12
  import type {
13
13
  AgentSessionEvent,
14
14
  CreateAgentSessionOptions,
15
15
  SessionStats,
16
16
  ToolDefinition,
17
- } from "@mariozechner/pi-coding-agent";
17
+ } from "@earendil-works/pi-coding-agent";
18
18
  import {
19
19
  type AgentSession,
20
20
  createAgentSession,
21
21
  DefaultResourceLoader,
22
22
  getAgentDir,
23
23
  SessionManager,
24
- } from "@mariozechner/pi-coding-agent";
24
+ } from "@earendil-works/pi-coding-agent";
25
25
  import { type TSchema, Type } from "typebox";
26
26
  import { scrubSecrets } from "../utils/secret-scrubber";
27
27
  import { createSwarmHooksExtension } from "./pi-mono-extension";
@@ -6,7 +6,7 @@
6
6
  * with full behavioral parity.
7
7
  */
8
8
 
9
- import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
9
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
10
10
  import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
11
11
  import { checkToolLoop, clearToolHistory } from "../hooks/tool-loop-detection";
12
12
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
package/src/server.ts CHANGED
@@ -13,6 +13,14 @@ import { registerGetTaskDetailsTool } from "./tools/get-task-details";
13
13
  import { registerGetTasksTool } from "./tools/get-tasks";
14
14
  import { registerInjectLearningTool } from "./tools/inject-learning";
15
15
  import { registerJoinSwarmTool } from "./tools/join-swarm";
16
+ // KV capability
17
+ import {
18
+ registerKvDeleteTool,
19
+ registerKvGetTool,
20
+ registerKvIncrTool,
21
+ registerKvListTool,
22
+ registerKvSetTool,
23
+ } from "./tools/kv";
16
24
  // Messaging capability
17
25
  import { registerListChannelsTool } from "./tools/list-channels";
18
26
  import { registerListServicesTool } from "./tools/list-services";
@@ -121,7 +129,8 @@ import {
121
129
 
122
130
  // Capability-based feature flags
123
131
  // Default: all capabilities enabled
124
- const DEFAULT_CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows,pages";
132
+ const DEFAULT_CAPABILITIES =
133
+ "core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
125
134
  const CAPABILITIES = new Set(
126
135
  (process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
127
136
  );
@@ -293,6 +302,16 @@ export function createServer() {
293
302
  registerCreatePageTool(server);
294
303
  }
295
304
 
305
+ // KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
306
+ // Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
307
+ if (hasCapability("kv")) {
308
+ registerKvGetTool(server);
309
+ registerKvSetTool(server);
310
+ registerKvDeleteTool(server);
311
+ registerKvIncrTool(server);
312
+ registerKvListTool(server);
313
+ }
314
+
296
315
  // MCP Servers - always registered
297
316
  registerMcpServerCreateTool(server);
298
317
  registerMcpServerUpdateTool(server);
@@ -6,6 +6,11 @@
6
6
  * a task belongs to. We persist the key on `agent_tasks.contextKey` so that a
7
7
  * single indexed lookup can return all sibling tasks for a given entity.
8
8
  *
9
+ * The same vocabulary is reused by the KV store (`kv_entries.namespace`) so a
10
+ * caller can read/write state scoped to the entity that triggered the call
11
+ * (Slack thread, PR, Linear issue, agent, page, ...) without inventing a
12
+ * separate namespace scheme.
13
+ *
9
14
  * Key schema:
10
15
  * task:slack:{channelId}:{threadTs}
11
16
  * task:agentmail:{threadId}
@@ -15,6 +20,8 @@
15
20
  * task:trackers:jira:{issueIdentifier} (e.g. PROJ-123 — case preserved)
16
21
  * task:schedule:{scheduleId}
17
22
  * task:workflow:{workflowRunId}
23
+ * task:agent:{agentId} (KV-only: per-agent scratchpad)
24
+ * task:page:{pageId} (KV-only: per-page state, proxy-enforced)
18
25
  *
19
26
  * Rules:
20
27
  * - Fixed prefix tokens (`task`, family, sub-family, kind) are always lowercase.
@@ -157,6 +164,27 @@ export function workflowContextKey(input: { workflowRunId: string }): string {
157
164
  return ["task", "workflow", workflowRunId].join(SEPARATOR);
158
165
  }
159
166
 
167
+ /**
168
+ * Per-agent KV scratchpad namespace. Not used as a task `contextKey` (tasks
169
+ * always derive their context from an ingress entity), but reused here so the
170
+ * KV layer can resolve "the caller has no task header, just an agent id" into
171
+ * a stable namespace string with the same shape as everything else.
172
+ */
173
+ export function agentContextKey(input: { agentId: string }): string {
174
+ const agentId = assertSafePart(input.agentId, "agentId");
175
+ return ["task", "agent", agentId].join(SEPARATOR);
176
+ }
177
+
178
+ /**
179
+ * Per-page KV namespace. Enforced at the page-proxy boundary
180
+ * (src/http/page-proxy.ts) — pages can never write outside their own
181
+ * `task:page:<id>` namespace regardless of what the request body or URL says.
182
+ */
183
+ export function pageContextKey(input: { pageId: string }): string {
184
+ const pageId = assertSafePart(input.pageId, "pageId");
185
+ return ["task", "page", pageId].join(SEPARATOR);
186
+ }
187
+
160
188
  /**
161
189
  * Parse a context key back into a structured form. Throws on malformed input.
162
190
  * Useful for diagnostics and downstream routing; not used on the hot insert path.
package/src/telemetry.ts CHANGED
@@ -14,11 +14,60 @@ const TIMEOUT_MS = 5_000;
14
14
 
15
15
  let installationId: string | null = null;
16
16
  let source = "unknown";
17
+ let cachedIsCloud = false;
17
18
 
18
19
  function isEnabled(): boolean {
19
20
  return process.env.ANONYMIZED_TELEMETRY !== "false";
20
21
  }
21
22
 
23
+ /**
24
+ * Hosts we own that indicate a cloud-pointed install. Exact-match for known
25
+ * hostnames + suffix-match for the cloud apexes so future cloud subdomains
26
+ * (`mcp.agent-swarm.dev`, `api.agent-swarm.cloud`, etc.) are automatically
27
+ * classified as cloud. Substring match is intentionally avoided —
28
+ * `agent-swarm.dev.attacker.com` must NOT be treated as cloud.
29
+ */
30
+ const CLOUD_HOST_EXACT = new Set<string>([
31
+ "agent-swarm-mcp.desplega.sh",
32
+ "agent-swarm.dev",
33
+ "agent-swarm.cloud",
34
+ ]);
35
+ const CLOUD_HOST_SUFFIXES = [".agent-swarm.dev", ".agent-swarm.cloud"];
36
+
37
+ function isCloudHostname(hostname: string): boolean {
38
+ if (!hostname) return false;
39
+ const normalized = hostname.toLowerCase();
40
+ if (CLOUD_HOST_EXACT.has(normalized)) return true;
41
+ return CLOUD_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
42
+ }
43
+
44
+ /**
45
+ * Parse `MCP_BASE_URL` (or any candidate URL) into the cloud flag we ship on
46
+ * every telemetry event. URL parsing — not substring match — so we never
47
+ * confuse an attacker-controlled `agent-swarm.dev.bad` for cloud. On any
48
+ * parse failure returns a safe `false` so callers never need to defend
49
+ * against this throwing.
50
+ *
51
+ * The hostname itself is intentionally NOT emitted — telemetry is anonymous,
52
+ * and leaking the deployment host would defeat that. Only the boolean
53
+ * cloud-cohort flag ships.
54
+ *
55
+ * Exported for tests; not part of the public API.
56
+ */
57
+ export function _resolveCloudMode(mcpBaseUrl: string | undefined | null): {
58
+ isCloud: boolean;
59
+ } {
60
+ if (!mcpBaseUrl) return { isCloud: false };
61
+ let hostname: string;
62
+ try {
63
+ hostname = new URL(mcpBaseUrl).hostname;
64
+ } catch {
65
+ return { isCloud: false };
66
+ }
67
+ if (!hostname) return { isCloud: false };
68
+ return { isCloud: isCloudHostname(hostname) };
69
+ }
70
+
22
71
  interface InitTelemetryOptions {
23
72
  /**
24
73
  * Whether to mint and persist a new install ID when the config read returns
@@ -48,6 +97,11 @@ export async function initTelemetry(
48
97
  if (!isEnabled()) return;
49
98
  source = sourceId;
50
99
  const generateIfMissing = options.generateIfMissing === true;
100
+
101
+ const resolved = _resolveCloudMode(process.env.MCP_BASE_URL);
102
+ cachedIsCloud = resolved.isCloud;
103
+ console.log(`telemetry: cloud=${cachedIsCloud}`);
104
+
51
105
  try {
52
106
  const existing = await getConfig("telemetry_installation_id");
53
107
  if (existing) {
@@ -110,7 +164,16 @@ export function track(options: TrackOptions): void {
110
164
  source,
111
165
  actor_mode: "anonymous" as const,
112
166
  actor_anonymous_id: installationId,
113
- properties: options.properties ?? {},
167
+ properties: {
168
+ ...(options.properties ?? {}),
169
+ // Cloud-cohort signal derived from MCP_BASE_URL at init time.
170
+ // Placed at the top level of `properties_json` so ClickHouse can
171
+ // GROUP BY without descending into nested objects. Spread LAST so
172
+ // caller-supplied keys can never spoof the cohort classification.
173
+ // The hostname is intentionally NOT included — telemetry must stay
174
+ // anonymous, and the boolean is sufficient to split cloud vs self-host.
175
+ is_cloud: cachedIsCloud,
176
+ },
114
177
  metadata: {
115
178
  transport: "https",
116
179
  schema_version: 1,
@@ -138,6 +201,7 @@ export function track(options: TrackOptions): void {
138
201
  export function _resetTelemetryStateForTests(): void {
139
202
  installationId = null;
140
203
  source = "unknown";
204
+ cachedIsCloud = false;
141
205
  }
142
206
 
143
207
  /** Test-only: read the resolved install ID. */