@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.
- package/README.md +2 -0
- package/openapi.json +559 -1
- package/package.json +4 -4
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +149 -0
- package/src/artifact-sdk/browser-sdk.ts +292 -0
- package/src/be/db.ts +309 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +2 -0
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +5 -0
- package/src/http/pages-public.ts +50 -6
- package/src/http/status.ts +1 -1
- package/src/providers/claude-adapter.ts +138 -7
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +20 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/claude-adapter-binary.test.ts +628 -0
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/page-proxy.test.ts +5 -1
- package/src/tests/page-session.test.ts +10 -5
- package/src/tests/pages-authed-mode.test.ts +5 -1
- package/src/tests/pages-public-html.test.ts +10 -1
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +53 -0
- package/src/utils/internal-ai/complete-structured.ts +7 -10
- package/src/utils/internal-ai/credentials.ts +3 -3
package/src/http/pages-public.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/http/status.ts
CHANGED
|
@@ -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
|
-
* `@
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
552
|
-
|
|
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
|
-
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 =
|
|
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);
|
package/src/tasks/context-key.ts
CHANGED
|
@@ -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:
|
|
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. */
|