@desplega.ai/agent-swarm 1.89.0 → 1.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/openapi.json +74 -1
  3. package/package.json +6 -6
  4. package/plugin/skills/composio/SKILL.md +138 -63
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/artifact-sdk/server.ts +2 -1
  9. package/src/be/db.ts +28 -0
  10. package/src/be/memory/providers/sqlite-store.ts +6 -1
  11. package/src/be/memory/types.ts +1 -0
  12. package/src/be/modelsdev-cache.json +752 -81
  13. package/src/be/scripts/typecheck.ts +132 -1
  14. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  15. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  16. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  17. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  18. package/src/be/seed-scripts/index.ts +36 -0
  19. package/src/commands/artifact.ts +3 -2
  20. package/src/commands/profile-sync.ts +310 -0
  21. package/src/commands/runner.ts +91 -1
  22. package/src/heartbeat/heartbeat.ts +54 -7
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/index.ts +47 -0
  25. package/src/http/integrations.ts +6 -1
  26. package/src/http/mcp-bridge.ts +117 -0
  27. package/src/http/mcp-oauth.ts +97 -39
  28. package/src/http/memory.ts +5 -2
  29. package/src/http/openapi.ts +2 -2
  30. package/src/http/pages-public.ts +10 -11
  31. package/src/http/pages.ts +7 -11
  32. package/src/http/scripts.ts +24 -1
  33. package/src/http/tasks.ts +2 -0
  34. package/src/http/utils.ts +11 -4
  35. package/src/jira/app.ts +2 -3
  36. package/src/jira/webhook-lifecycle.ts +2 -1
  37. package/src/linear/app.ts +2 -3
  38. package/src/providers/claude-adapter.ts +26 -0
  39. package/src/scripts-runtime/executors/native.ts +1 -0
  40. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  42. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  44. package/src/tasks/worker-follow-up.ts +19 -1
  45. package/src/tests/claude-adapter-otel.test.ts +85 -1
  46. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  47. package/src/tests/hook-registration-nudge.test.ts +69 -0
  48. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  49. package/src/tests/pages-public-html.test.ts +41 -0
  50. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  51. package/src/tests/profile-sync.test.ts +282 -0
  52. package/src/tests/scripts-runtime.test.ts +33 -0
  53. package/src/tests/seed-scripts.test.ts +2 -2
  54. package/src/tools/create-metric.ts +2 -3
  55. package/src/tools/create-page.ts +3 -6
  56. package/src/tools/memory-rate.ts +2 -1
  57. package/src/tools/memory-search.ts +1 -0
  58. package/src/tools/register-kapso-number.ts +2 -4
  59. package/src/tools/request-human-input.ts +2 -1
  60. package/src/tools/script-common.ts +2 -4
  61. package/src/tools/script-run.ts +7 -0
  62. package/src/utils/constants.ts +58 -8
  63. package/templates/skills/swarm-scripts/content.md +46 -7
@@ -0,0 +1,282 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import {
3
+ buildIdentityPayload,
4
+ CLAUDE_MD_PATH,
5
+ collectProfilePayloads,
6
+ extractSetupScriptContent,
7
+ type FileReader,
8
+ IDENTITY_MD_PATH,
9
+ postProfileUpdate,
10
+ resolveClaudeMdPath,
11
+ SETUP_SCRIPT_PATH,
12
+ SOUL_MD_PATH,
13
+ syncProfileFilesToServer,
14
+ TOOLS_MD_PATH,
15
+ WORKSPACE_CLAUDE_MD_PATH,
16
+ } from "../commands/profile-sync";
17
+
18
+ const MARKER_START = "# === Agent-managed setup (from DB) ===";
19
+ const MARKER_END = "# === End agent-managed setup ===";
20
+
21
+ // A SOUL/IDENTITY body long enough to clear the 500-char min-length guard.
22
+ const LONG = "x".repeat(600);
23
+
24
+ describe("extractSetupScriptContent (marker extraction)", () => {
25
+ test("extracts ONLY the content between the agent-managed markers", () => {
26
+ const raw = [
27
+ "#!/bin/bash",
28
+ "echo operator-prelude",
29
+ MARKER_START,
30
+ "export FOO=bar",
31
+ 'echo "agent line"',
32
+ MARKER_END,
33
+ "echo operator-postlude",
34
+ ].join("\n");
35
+
36
+ expect(extractSetupScriptContent(raw)).toBe('export FOO=bar\necho "agent line"');
37
+ });
38
+
39
+ test("strips a leading shebang when no markers are present", () => {
40
+ const raw = '#!/bin/bash\necho "whole file is agent-managed"';
41
+ expect(extractSetupScriptContent(raw)).toBe('echo "whole file is agent-managed"');
42
+ });
43
+
44
+ test("returns null for an empty / whitespace-only file", () => {
45
+ expect(extractSetupScriptContent("")).toBeNull();
46
+ expect(extractSetupScriptContent(" \n\t ")).toBeNull();
47
+ });
48
+
49
+ test("returns null when the marker section is empty", () => {
50
+ const raw = `prelude\n${MARKER_START}\n \n${MARKER_END}\npostlude`;
51
+ expect(extractSetupScriptContent(raw)).toBeNull();
52
+ });
53
+
54
+ test("returns null when content exceeds the max length", () => {
55
+ const raw = `${MARKER_START}\n${"a".repeat(65537)}\n${MARKER_END}`;
56
+ expect(extractSetupScriptContent(raw)).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe("buildIdentityPayload (min-length guard)", () => {
61
+ test("includes SOUL/IDENTITY only when they clear the 500-char minimum", () => {
62
+ const errSpy = spyOn(console, "error").mockImplementation(() => {});
63
+ try {
64
+ const ok = buildIdentityPayload({ soulMd: LONG, identityMd: LONG });
65
+ expect(ok.soulMd).toBe(LONG);
66
+ expect(ok.identityMd).toBe(LONG);
67
+
68
+ const short = buildIdentityPayload({ soulMd: "too short", identityMd: "also short" });
69
+ expect(short.soulMd).toBeUndefined();
70
+ expect(short.identityMd).toBeUndefined();
71
+ // The guard must be VISIBLE — it logs why it skipped.
72
+ expect(errSpy).toHaveBeenCalled();
73
+ } finally {
74
+ errSpy.mockRestore();
75
+ }
76
+ });
77
+
78
+ test("TOOLS.md has no min-length guard (any non-empty content syncs)", () => {
79
+ const payload = buildIdentityPayload({ toolsMd: "short tools" });
80
+ expect(payload.toolsMd).toBe("short tools");
81
+ });
82
+
83
+ test("HEARTBEAT.md syncs even when empty (no trim/min-length guard)", () => {
84
+ const payload = buildIdentityPayload({ heartbeatMd: "" });
85
+ expect(payload.heartbeatMd).toBe("");
86
+ });
87
+
88
+ test("skips files that exceed the max length", () => {
89
+ const huge = "z".repeat(65537);
90
+ const payload = buildIdentityPayload({ soulMd: huge, toolsMd: huge });
91
+ expect(payload.soulMd).toBeUndefined();
92
+ expect(payload.toolsMd).toBeUndefined();
93
+ });
94
+
95
+ test("absent files (undefined) produce no keys", () => {
96
+ expect(buildIdentityPayload({})).toEqual({});
97
+ });
98
+ });
99
+
100
+ describe("collectProfilePayloads (field gate)", () => {
101
+ const reader = (files: Record<string, string>): FileReader => {
102
+ return async (path: string) => files[path];
103
+ };
104
+
105
+ test("only the selected field group is collected", async () => {
106
+ const files = reader({
107
+ [SOUL_MD_PATH]: LONG,
108
+ [IDENTITY_MD_PATH]: LONG,
109
+ [TOOLS_MD_PATH]: "tools",
110
+ [CLAUDE_MD_PATH]: "claude md content",
111
+ [SETUP_SCRIPT_PATH]: `${MARKER_START}\nexport X=1\n${MARKER_END}`,
112
+ });
113
+
114
+ const setupOnly = await collectProfilePayloads(["setup"], "session_sync", files);
115
+ expect(setupOnly.map((p) => p.label)).toEqual(["setup"]);
116
+ expect(setupOnly[0]?.body).toEqual({ setupScript: "export X=1", changeSource: "session_sync" });
117
+
118
+ const claudeOnly = await collectProfilePayloads(["claude"], "session_sync", files);
119
+ expect(claudeOnly.map((p) => p.label)).toEqual(["claude"]);
120
+
121
+ const all = await collectProfilePayloads(
122
+ ["identity", "claude", "setup"],
123
+ "session_sync",
124
+ files,
125
+ );
126
+ expect(all.map((p) => p.label).sort()).toEqual(["claude", "identity", "setup"]);
127
+ });
128
+
129
+ test("a missing file yields no payload for that group (no empty POST)", async () => {
130
+ const files = reader({}); // nothing on disk
131
+ const payloads = await collectProfilePayloads(
132
+ ["identity", "claude", "setup"],
133
+ "session_sync",
134
+ files,
135
+ );
136
+ expect(payloads).toEqual([]);
137
+ });
138
+
139
+ test("propagates the changeSource into every body", async () => {
140
+ const files = reader({ [TOOLS_MD_PATH]: "tools" });
141
+ const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
142
+ expect(payloads[0]?.body.changeSource).toBe("self_edit");
143
+ });
144
+
145
+ test("non-Claude providers sync /workspace/CLAUDE.md, not the personal file", async () => {
146
+ // A codex/pi/opencode session edits the runner-materialized workspace file;
147
+ // the Claude personal file (~/.claude/CLAUDE.md) is absent for them.
148
+ const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "workspace claude md edit" });
149
+
150
+ const payloads = await collectProfilePayloads(
151
+ ["claude"],
152
+ "session_sync",
153
+ files,
154
+ WORKSPACE_CLAUDE_MD_PATH,
155
+ );
156
+ expect(payloads.map((p) => p.label)).toEqual(["claude"]);
157
+ expect(payloads[0]?.body).toEqual({
158
+ claudeMd: "workspace claude md edit",
159
+ changeSource: "session_sync",
160
+ });
161
+ });
162
+
163
+ test("Claude's default path never reads the workspace materialization", async () => {
164
+ // Guard against reverting a real Claude personal-file edit: with the default
165
+ // (personal-file) path, content sitting only at /workspace/CLAUDE.md — the
166
+ // stale boot materialization — must NOT be picked up as a claude payload.
167
+ const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "stale workspace materialization" });
168
+
169
+ const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
170
+ expect(payloads).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("resolveClaudeMdPath (per-batch provider routing)", () => {
175
+ test("an all-Claude batch uses the personal-file path (Stop-hook backstop)", () => {
176
+ expect(resolveClaudeMdPath(["claude"])).toBe(CLAUDE_MD_PATH);
177
+ expect(resolveClaudeMdPath(["claude", "claude"])).toBe(CLAUDE_MD_PATH);
178
+ });
179
+
180
+ test("any non-Claude local session routes to the workspace file", () => {
181
+ expect(resolveClaudeMdPath(["codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
182
+ expect(resolveClaudeMdPath(["pi"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
183
+ expect(resolveClaudeMdPath(["opencode"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
184
+ // Mixed batch: a non-Claude edit means the workspace file is authoritative.
185
+ expect(resolveClaudeMdPath(["claude", "codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
186
+ });
187
+ });
188
+
189
+ describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
190
+ const opts = {
191
+ agentId: "agent-1",
192
+ apiUrl: "https://api.example.test",
193
+ apiKey: "secret-key",
194
+ };
195
+ const payload = {
196
+ label: "setup",
197
+ body: { setupScript: "export X=1", changeSource: "session_sync" },
198
+ };
199
+
200
+ test("a successful 2xx response logs no warning", async () => {
201
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
202
+ const fetchImpl = (async () => new Response("{}", { status: 200 })) as typeof fetch;
203
+ try {
204
+ await postProfileUpdate({ ...opts, fetchImpl }, payload);
205
+ expect(warnSpy).not.toHaveBeenCalled();
206
+ } finally {
207
+ warnSpy.mockRestore();
208
+ }
209
+ });
210
+
211
+ test("a non-2xx response surfaces a warning but does NOT throw", async () => {
212
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
213
+ const fetchImpl = (async () =>
214
+ new Response("boom", { status: 500, statusText: "Server Error" })) as typeof fetch;
215
+ try {
216
+ // Must resolve (non-fatal), not reject.
217
+ await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
218
+ expect(warnSpy).toHaveBeenCalledTimes(1);
219
+ const msg = String(warnSpy.mock.calls[0]?.[0]);
220
+ expect(msg).toContain("setup sync failed");
221
+ expect(msg).toContain("500");
222
+ } finally {
223
+ warnSpy.mockRestore();
224
+ }
225
+ });
226
+
227
+ test("a thrown fetch error surfaces a warning but does NOT throw", async () => {
228
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
229
+ const fetchImpl = (async () => {
230
+ throw new Error("network down");
231
+ }) as typeof fetch;
232
+ try {
233
+ await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
234
+ expect(warnSpy).toHaveBeenCalledTimes(1);
235
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain("setup sync errored");
236
+ } finally {
237
+ warnSpy.mockRestore();
238
+ }
239
+ });
240
+
241
+ test("sends a PUT to the profile route with auth + agent headers", async () => {
242
+ let capturedUrl = "";
243
+ let capturedInit: RequestInit | undefined;
244
+ const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
245
+ capturedUrl = String(url);
246
+ capturedInit = init;
247
+ return new Response("{}", { status: 200 });
248
+ }) as typeof fetch;
249
+
250
+ await postProfileUpdate({ ...opts, fetchImpl }, payload);
251
+
252
+ expect(capturedUrl).toBe("https://api.example.test/api/agents/agent-1/profile");
253
+ expect(capturedInit?.method).toBe("PUT");
254
+ const headers = capturedInit?.headers as Record<string, string>;
255
+ expect(headers.Authorization).toBe("Bearer secret-key");
256
+ expect(headers["X-Agent-ID"]).toBe("agent-1");
257
+ expect(JSON.parse(String(capturedInit?.body))).toEqual(payload.body);
258
+ });
259
+ });
260
+
261
+ describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
262
+ test("resolves without throwing even when every POST fails", async () => {
263
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
264
+ const errSpy = spyOn(console, "error").mockImplementation(() => {});
265
+ const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch;
266
+ try {
267
+ await expect(
268
+ syncProfileFilesToServer({
269
+ agentId: "agent-1",
270
+ apiUrl: "https://api.example.test",
271
+ apiKey: "secret-key",
272
+ changeSource: "session_sync",
273
+ // No files on a CI box → typically no payloads; still must never throw.
274
+ fetchImpl,
275
+ }),
276
+ ).resolves.toBeUndefined();
277
+ } finally {
278
+ warnSpy.mockRestore();
279
+ errSpy.mockRestore();
280
+ }
281
+ });
282
+ });
@@ -288,6 +288,39 @@ describe("runScript", () => {
288
288
  }
289
289
  });
290
290
 
291
+ test("zod import works in compiled binary mode (SCRIPT_RUNTIME_DIR)", async () => {
292
+ const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-runtime-test-${crypto.randomUUID()}`;
293
+ await Bun.$`mkdir -p ${tmpdir}`;
294
+ try {
295
+ const runtimeSrc = new URL("../scripts-runtime", import.meta.url).pathname;
296
+ await Bun.$`bun build ${runtimeSrc}/eval-harness.ts --target bun --no-splitting --outfile ${tmpdir}/eval-harness.bundle.js`.quiet();
297
+ await Bun.$`bun build ${runtimeSrc}/stdlib/index.ts --target bun --no-splitting --outfile ${tmpdir}/stdlib.bundle.js`.quiet();
298
+ await Bun.$`bun build ${runtimeSrc}/swarm-sdk.ts --target bun --no-splitting --outfile ${tmpdir}/swarm-sdk.bundle.js`.quiet();
299
+ const zodEntry = Bun.resolveSync("zod", import.meta.dir);
300
+ await Bun.$`bun build ${zodEntry} --target bun --no-splitting --outfile ${tmpdir}/zod.bundle.js`.quiet();
301
+
302
+ process.env.SCRIPT_RUNTIME_DIR = tmpdir;
303
+
304
+ const output = await runScript({
305
+ agentId: "agent-1",
306
+ args: { name: "test" },
307
+ resources,
308
+ source: `
309
+ import { z } from "zod";
310
+ export const argsSchema = z.object({ name: z.string() });
311
+ export default async (args: z.infer<typeof argsSchema>) => ({ greeting: "hello " + args.name });
312
+ `,
313
+ });
314
+
315
+ expect(output.error).toBeUndefined();
316
+ expect(output.result).toEqual({ greeting: "hello test" });
317
+ expect(output.exitCode).toBe(0);
318
+ } finally {
319
+ delete process.env.SCRIPT_RUNTIME_DIR;
320
+ await Bun.$`rm -rf ${tmpdir}`;
321
+ }
322
+ });
323
+
291
324
  test("argsSchema rejects invalid args with a formatted Zod error", async () => {
292
325
  const output = await runScript({
293
326
  agentId: "agent-1",
@@ -48,8 +48,8 @@ afterAll(async () => {
48
48
  });
49
49
 
50
50
  describe("seed-scripts catalog", () => {
51
- test("manifest holds 10 unique, well-described scripts", () => {
52
- expect(SEED_SCRIPTS.length).toBe(10);
51
+ test("manifest holds 14 unique, well-described scripts", () => {
52
+ expect(SEED_SCRIPTS.length).toBe(14);
53
53
  const names = SEED_SCRIPTS.map((s) => s.name);
54
54
  expect(new Set(names).size).toBe(names.length);
55
55
  for (const s of SEED_SCRIPTS) {
@@ -5,6 +5,7 @@ import { assertSelectOnlyQuery } from "@/http/db-query";
5
5
  import { snapshotMetric } from "@/metrics/version";
6
6
  import { createToolRegistrar } from "@/tools/utils";
7
7
  import { MetricDefinitionSchema } from "@/types";
8
+ import { getAppUrl } from "@/utils/constants";
8
9
 
9
10
  function slugify(input: string): string {
10
11
  const slug = input
@@ -16,9 +17,7 @@ function slugify(input: string): string {
16
17
  }
17
18
 
18
19
  function getAppBaseUrl(): string {
19
- const env = process.env.APP_URL?.trim();
20
- if (env) return env.replace(/\/+$/, "");
21
- return "http://localhost:5274";
20
+ return getAppUrl();
22
21
  }
23
22
 
24
23
  function metricEditCounter(metricId: string): number {
@@ -23,6 +23,7 @@ import { createPage, getPage, getPageBySlug, getPageVersions, updatePage } from
23
23
  import { snapshotPage } from "@/pages/version";
24
24
  import { createToolRegistrar } from "@/tools/utils";
25
25
  import { PageAuthModeSchema, PageContentTypeSchema } from "@/types";
26
+ import { getAppUrl, getPublicMcpBaseUrl } from "@/utils/constants";
26
27
 
27
28
  /** Same slugifier used by the HTTP createPage handler. */
28
29
  function slugify(input: string): string {
@@ -35,15 +36,11 @@ function slugify(input: string): string {
35
36
  }
36
37
 
37
38
  function getApiBaseUrl(): string {
38
- const env = process.env.MCP_BASE_URL?.trim();
39
- if (env) return env.replace(/\/+$/, "");
40
- return `http://localhost:${process.env.PORT || "3013"}`;
39
+ return getPublicMcpBaseUrl();
41
40
  }
42
41
 
43
42
  function getAppBaseUrl(): string {
44
- const env = process.env.APP_URL?.trim();
45
- if (env) return env.replace(/\/+$/, "");
46
- return "http://localhost:5274";
43
+ return getAppUrl();
47
44
  }
48
45
 
49
46
  /**
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { REFERENCES_SOURCE_MAX_LENGTH, sanitizeReferencesSource } from "@/be/memory/raters/types";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { getApiKey } from "@/utils/api-key";
6
+ import { getMcpBaseUrl } from "@/utils/constants";
6
7
 
7
8
  /**
8
9
  * Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-5.md §1
@@ -92,7 +93,7 @@ export const registerMemoryRateTool = (server: McpServer) => {
92
93
  cleanedReferencesSource = cleaned;
93
94
  }
94
95
 
95
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
96
+ const apiUrl = getMcpBaseUrl();
96
97
  const apiKey = getApiKey();
97
98
 
98
99
  const event = {
@@ -125,6 +125,7 @@ export const registerMemorySearchTool = (server: McpServer) => {
125
125
  scope: scope as "agent" | "swarm" | "all",
126
126
  limit,
127
127
  isLead,
128
+ source,
128
129
  });
129
130
 
130
131
  const mapped = recent.map((r) => ({
@@ -10,13 +10,11 @@ import {
10
10
  putKapsoNumberMapping,
11
11
  } from "@/integrations/kapso/config";
12
12
  import { createToolRegistrar } from "@/tools/utils";
13
+ import { getPublicMcpBaseUrl } from "@/utils/constants";
13
14
 
14
15
  /** Build the native inbound webhook URL the swarm exposes for Kapso deliveries. */
15
16
  function nativeWebhookUrl(): string {
16
- const base = (
17
- process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`
18
- ).replace(/\/+$/, "");
19
- return `${base}/api/integrations/kapso/webhook`;
17
+ return `${getPublicMcpBaseUrl()}/api/integrations/kapso/webhook`;
20
18
  }
21
19
 
22
20
  export const registerRegisterKapsoNumberTool = (server: McpServer) => {
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
+ import { getAppUrl } from "@/utils/constants";
5
6
 
6
7
  const QuestionSchema = z.object({
7
8
  id: z.string().describe("Unique ID for the question (used as key in responses)"),
@@ -94,7 +95,7 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
94
95
  timeoutSeconds,
95
96
  });
96
97
 
97
- const appUrl = process.env.APP_URL || "http://localhost:5274";
98
+ const appUrl = getAppUrl();
98
99
  const url = `${appUrl}/approval-requests/${request.id}`;
99
100
 
100
101
  return {
@@ -1,6 +1,7 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import * as z from "zod";
3
3
  import { getApiKey } from "@/utils/api-key";
4
+ import { getMcpBaseUrl } from "@/utils/constants";
4
5
  import type { RequestInfo } from "./utils";
5
6
 
6
7
  export const SCRIPT_TRANSPORT_ERROR =
@@ -20,10 +21,7 @@ export const scriptToolOutputSchema = z.object({
20
21
  export type ScriptToolStructuredContent = z.infer<typeof scriptToolOutputSchema>;
21
22
 
22
23
  function apiBaseUrl(): string {
23
- return (process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`).replace(
24
- /\/+$/,
25
- "",
26
- );
24
+ return getMcpBaseUrl();
27
25
  }
28
26
 
29
27
  function toolError(message: string, status = 400): CallToolResult {
@@ -28,6 +28,13 @@ export const registerScriptRunTool = (server: McpServer) => {
28
28
  fsMode: scriptFsModeSchema
29
29
  .default("none")
30
30
  .describe("Filesystem mode. v1 supports none only."),
31
+ idempotencyKey: z
32
+ .string()
33
+ .max(200)
34
+ .optional()
35
+ .describe(
36
+ "When set, output is auto-persisted to kv under script:executions/{key}. Re-running with the same key overwrites. Queryable via kv-get.",
37
+ ),
31
38
  }),
32
39
  outputSchema: scriptToolOutputSchema,
33
40
  },
@@ -3,19 +3,69 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Default dashboard URL used when `APP_URL` is unset. Points at the public
7
- * production dashboard so links (Slack messages, approval URLs, etc.) are
8
- * always renderable. Self-hosted operators should set `APP_URL` to override.
6
+ * Default dashboard URL used when neither `APP_URL` nor the deprecated
7
+ * `DASHBOARD_URL` is set. Points at the public production dashboard so links
8
+ * (Slack messages, approval URLs, page share links, post-OAuth redirects)
9
+ * stay renderable even when an operator forgets to configure it. Local dev
10
+ * should set `APP_URL` (e.g. in `.env`) to point at the local dashboard.
9
11
  */
10
12
  export const DEFAULT_APP_URL = "https://app.agent-swarm.dev";
11
13
 
12
14
  /**
13
- * Resolve the effective app/dashboard URL from `APP_URL` (with trailing
14
- * slashes stripped), falling back to {@link DEFAULT_APP_URL}.
15
+ * Resolve every explicitly configured app/dashboard URL. Each env var may be
16
+ * a comma-separated origin list; entries are returned in precedence order with
17
+ * trailing slashes stripped.
18
+ *
19
+ * Precedence: `APP_URL` entries → `DASHBOARD_URL` entries (deprecated alias,
20
+ * kept for back-compat).
21
+ */
22
+ export function getConfiguredAppUrls(): string[] {
23
+ return [process.env.APP_URL, process.env.DASHBOARD_URL]
24
+ .flatMap((value) => (value ?? "").split(","))
25
+ .map((value) => value.trim().replace(/\/+$/, ""))
26
+ .filter(Boolean);
27
+ }
28
+
29
+ /**
30
+ * Resolve the effective app/dashboard URL — the public origin the user's
31
+ * browser is sent to (post-login redirects, Slack/approval links, page
32
+ * `app_url` share links). Trailing slashes are stripped.
33
+ *
34
+ * Precedence: first configured `APP_URL` entry → first configured
35
+ * `DASHBOARD_URL` entry (deprecated alias, kept for back-compat) → fallback.
36
+ * This is the single source of truth; call sites must not re-read
37
+ * `APP_URL`/`DASHBOARD_URL` directly.
38
+ */
39
+ export function getAppUrl(fallback = DEFAULT_APP_URL): string {
40
+ return (getConfiguredAppUrls()[0] || fallback).replace(/\/+$/, "");
41
+ }
42
+
43
+ /**
44
+ * Internal API/MCP base URL — how workers/agents and in-process callers reach
45
+ * the API server. May be a private/cluster address (e.g. the Helm ClusterIP
46
+ * `http://<release>-api:3013`). Do NOT use for browser-facing or
47
+ * externally-registered URLs (OAuth redirect URIs, webhook URLs): those must
48
+ * resolve to a host the browser / third party can reach — use
49
+ * {@link getPublicMcpBaseUrl} (no request context) or `deriveApiBaseUrl(req)`
50
+ * (request-scoped) instead. Trailing slashes are stripped.
51
+ */
52
+ export function getMcpBaseUrl(): string {
53
+ const raw = process.env.MCP_BASE_URL?.trim();
54
+ return (raw || `http://localhost:${process.env.PORT || "3013"}`).replace(/\/+$/, "");
55
+ }
56
+
57
+ /**
58
+ * Public, browser-/externally-reachable origin of the API server — where
59
+ * `/api/mcp-oauth/callback`, OAuth redirect URIs, and registered webhook URLs
60
+ * resolve. Falls back to {@link getMcpBaseUrl} when the public and internal
61
+ * hosts are the same (local dev, single-box, or an ngrok/tunnel set as
62
+ * `MCP_BASE_URL`). In split deployments (Helm), set `PUBLIC_MCP_BASE_URL` to
63
+ * the public ingress URL while `MCP_BASE_URL` stays the internal service
64
+ * address. Trailing slashes are stripped.
15
65
  */
16
- export function getAppUrl(): string {
17
- const raw = process.env.APP_URL?.trim();
18
- return (raw || DEFAULT_APP_URL).replace(/\/+$/, "");
66
+ export function getPublicMcpBaseUrl(): string {
67
+ const raw = process.env.PUBLIC_MCP_BASE_URL?.trim();
68
+ return raw ? raw.replace(/\/+$/, "") : getMcpBaseUrl();
19
69
  }
20
70
 
21
71
  /**
@@ -32,16 +32,18 @@ Use `script-query-types` before non-trivial work so the script matches the live
32
32
  Use `script-run` with inline source for one-off work:
33
33
 
34
34
  ```typescript
35
- export default async function main(args: { status: string; limit: number }, ctx) {
35
+ export default async function main(args: any, ctx: any) {
36
36
  const { swarm, logger } = ctx;
37
- const result = await swarm.task_list({ status: args.status, limit: args.limit });
38
- logger.info(`Fetched ${result.tasks.length} tasks`);
37
+ // All SDK methods return Promise<unknown> unwrap defensively.
38
+ const res: any = await swarm.task_list({ status: args?.status, limit: args?.limit ?? 50 });
39
+ const tasks: any[] = res?.data?.tasks ?? res?.tasks ?? [];
40
+ logger.info(`Fetched ${tasks.length} tasks`);
39
41
  return {
40
- total: result.tasks.length,
41
- tasks: result.tasks.map((task) => ({
42
+ total: tasks.length,
43
+ tasks: tasks.map((task: any) => ({
42
44
  id: task.id,
43
45
  status: task.status,
44
- title: task.task.slice(0, 120),
46
+ title: task.task?.slice(0, 120),
45
47
  })),
46
48
  };
47
49
  }
@@ -60,8 +62,45 @@ Good named scripts:
60
62
  - Fan out over many swarm tasks, memories, repos, or schedules.
61
63
  - Convert noisy JSON or HTML into a compact summary.
62
64
 
65
+ ## Using `db_query` For Aggregation
66
+
67
+ For scripts that aggregate over tasks, sessions, or memory, `ctx.swarm.db_query` with direct SQL is far more efficient than fetching lists client-side.
68
+
69
+ **The parameter is `sql`, not `query`:**
70
+
71
+ ```typescript
72
+ // CORRECT
73
+ const res = await ctx.swarm.db_query({ sql: "SELECT status, count(*) as cnt FROM agent_tasks GROUP BY status" });
74
+
75
+ // WRONG — silently returns no data
76
+ const res = await ctx.swarm.db_query({ query: "SELECT ..." });
77
+ ```
78
+
79
+ **`db_query` returns positional rows, not objects.** The response shape is `{ rows: unknown[][], columns: string[] }`. Zip them into objects:
80
+
81
+ ```typescript
82
+ function rowsToObjects(res: any): any[] {
83
+ const p = res?.data ?? res;
84
+ const cols: string[] = p?.columns ?? [];
85
+ return (p?.rows ?? []).map((r: any) =>
86
+ Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
87
+ );
88
+ }
89
+
90
+ const rows = rowsToObjects(await ctx.swarm.db_query({
91
+ sql: `SELECT status, count(*) as cnt FROM agent_tasks WHERE createdAt > datetime('now','-3 days') GROUP BY status`,
92
+ }));
93
+ // rows = [{ status: "completed", cnt: 42 }, ...]
94
+ ```
95
+
96
+ **Common tables:** `agent_tasks` (tasks), `session_logs` (tool call logs), `agent_memory` (memories), `scheduled_tasks` (schedules), `agents` (agent registry).
97
+
98
+ **`session_logs` has no `tool_name` column.** Tool names are embedded in the `content` JSON column. Extract them SQL-side with `instr`/`substr` or parse JSON in JS after fetching.
99
+
63
100
  ## SDK And Context Gotchas
64
101
 
102
+ - **`args` can be undefined.** When a script is called without arguments, `args` is `undefined`. Always guard: `argsSchema.safeParse(args || {})` or use optional chaining (`args?.field`).
103
+ - **All SDK methods return `Promise<unknown>`.** Never assume a specific return shape without defensive unwrapping (`res?.data?.tasks ?? res?.tasks ?? []`). Run `script-query-types` to see live type signatures — return types are `unknown` and actual shapes vary by endpoint.
65
104
  - `agentId` is propagated to scripts via the `X-Agent-ID` header, so SDK calls run as the invoking agent.
66
105
  - `taskId` is not ambient. If a script needs to call `ctx.swarm.task_storeProgress`, pass `taskId` explicitly in `args`.
67
106
  - Scripts invoked from a workflow script node may run with a workflow identity rather than a human or worker agent identity.
@@ -73,7 +112,7 @@ Good named scripts:
73
112
  Thread task identity explicitly:
74
113
 
75
114
  ```typescript
76
- export default async function main(args: { taskId: string; items: string[] }, ctx) {
115
+ export default async function main(args: { taskId: string; items: string[] }, ctx: any) {
77
116
  const { swarm } = ctx;
78
117
  await swarm.task_storeProgress({
79
118
  taskId: args.taskId,