@desplega.ai/agent-swarm 1.151.0 → 1.152.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 (104) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-d05sg0q4.js} +1 -1
  3. package/dist/{actions-2bgpyhww.js → actions-4sc0v342.js} +13 -13
  4. package/dist/{app-1706zhr1.js → app-2d0v6kt6.js} +3 -3
  5. package/dist/{assistant-bavke5p7.js → assistant-zs6wdnrf.js} +14 -14
  6. package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-3dwdnnsr.js} +3 -3
  7. package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-vhh33520.js} +4 -4
  8. package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-e1fnabcg.js} +2 -2
  9. package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-h58xqzzd.js} +3 -3
  10. package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-pbaczbgt.js} +3 -3
  11. package/dist/{cli-a1z3h107.js → cli-4fqn2vpg.js} +4 -4
  12. package/dist/{cli-g69fdc9g.js → cli-5bqt4gwa.js} +2 -2
  13. package/dist/{cli-gjyvhmmh.js → cli-5p007m2q.js} +5 -5
  14. package/dist/{cli-5jd2z7av.js → cli-6k2athpf.js} +6 -2
  15. package/dist/{cli-5tpgq60f.js → cli-72vksk1n.js} +152 -23
  16. package/dist/{cli-vwm416m6.js → cli-87hdfyde.js} +1 -1
  17. package/dist/{cli-3v9xhjcn.js → cli-8fy9dmpv.js} +2 -2
  18. package/dist/{cli-czjtgfys.js → cli-awb59f08.js} +2 -2
  19. package/dist/{cli-j9xczhaq.js → cli-cs9z1hw3.js} +1 -1
  20. package/dist/{cli-r2q6pevn.js → cli-cwvey7n1.js} +43 -21
  21. package/dist/{cli-ppm4975n.js → cli-evgy5w3y.js} +1 -1
  22. package/dist/{cli-frcta2w4.js → cli-f1f9n77e.js} +1 -1
  23. package/dist/{cli-9004vfdf.js → cli-fhdssm2v.js} +2 -2
  24. package/dist/{cli-s14agm5s.js → cli-gacq5hg8.js} +12 -3
  25. package/dist/{cli-zkxymas5.js → cli-jh9txj4n.js} +1 -1
  26. package/dist/{cli-q88st9aj.js → cli-nd5s3m4y.js} +2 -2
  27. package/dist/{cli-sjaae6ff.js → cli-p2hx1pxm.js} +3 -3
  28. package/dist/{cli-2tbvf1z1.js → cli-t1mykkng.js} +8 -8
  29. package/dist/{cli-7tgcstn2.js → cli-t6axtdcg.js} +2 -2
  30. package/dist/{cli-f1wky6zp.js → cli-wp7cejrg.js} +1 -1
  31. package/dist/{cli-0qb1z6s2.js → cli-zdcdh02m.js} +1 -1
  32. package/dist/{cli-gbfwcd3k.js → cli-zfzznn7c.js} +7 -6
  33. package/dist/cli.js +11 -11
  34. package/dist/{codex-adapter-jjjk8w4m.js → codex-adapter-v3cz462q.js} +2 -2
  35. package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-de1a5g5z.js} +2 -2
  36. package/dist/{commands-9v4psyq1.js → commands-wfqqpn95.js} +2 -2
  37. package/dist/{db-wnt58tpe.js → db-9zzv5y05.js} +2 -2
  38. package/dist/{dispatcher-05jh1aqk.js → dispatcher-hfm1jbgx.js} +12 -12
  39. package/dist/{handlers-tw90pkh5.js → handlers-wrrhsack.js} +15 -15
  40. package/dist/{hook-sxe3r349.js → hook-9skye8zd.js} +1 -1
  41. package/dist/{hook-123e2scb.js → hook-fgjv62k5.js} +2 -2
  42. package/dist/{http-ecsar8ez.js → http-7grttjfq.js} +37 -34
  43. package/dist/{index-nnrbpcjp.js → index-29gxx9ar.js} +12 -12
  44. package/dist/{index-hw26vj40.js → index-30pz2h9w.js} +12 -12
  45. package/dist/{index-zj518gf9.js → index-4ek1df87.js} +13 -13
  46. package/dist/{index-16js521z.js → index-myfwqqyq.js} +8 -8
  47. package/dist/{index-ymn7em0p.js → index-pfhzrh1d.js} +3 -3
  48. package/dist/{keepalive-asqtcm4y.js → keepalive-j75m2m7k.js} +4 -4
  49. package/dist/{lead-xbfr7fvt.js → lead-a1hpjt0a.js} +16 -16
  50. package/dist/{maintenance-qma55qvw.js → maintenance-tngjzqdq.js} +4 -4
  51. package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-em1fn044.js} +3 -3
  52. package/dist/{onboard-drskd386.js → onboard-fv7ezvwx.js} +2 -2
  53. package/dist/{otel-impl-943c96er.js → otel-impl-6vbb568y.js} +1 -1
  54. package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-nmm949an.js} +4 -4
  55. package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-0fz5qryx.js} +4 -4
  56. package/dist/{rbac-roles-78baecqd.js → rbac-roles-xtp05syf.js} +3 -3
  57. package/dist/{render-v2-8zjntgf5.js → render-v2-fpb8whme.js} +3 -3
  58. package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-zwa8bs3t.js} +3 -3
  59. package/dist/{setup-4pks0vfk.js → setup-3ybwbdpd.js} +2 -2
  60. package/dist/{work-objects-wangt792.js → work-objects-rmayxcx7.js} +18 -17
  61. package/dist/{worker-p2mr422x.js → worker-rng4jhnh.js} +16 -16
  62. package/openapi.json +4 -4
  63. package/package.json +1 -1
  64. package/src/be/db.ts +1 -1
  65. package/src/be/scripts/typecheck.ts +4 -0
  66. package/src/be/swarm-config-guard.ts +7 -1
  67. package/src/extensions/lifecycle.ts +26 -4
  68. package/src/http/extensions.ts +8 -3
  69. package/src/rbac/legacy-policy.ts +13 -1
  70. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  71. package/src/scripts-runtime/types/stdlib.d.ts +4 -0
  72. package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
  73. package/src/server.ts +12 -0
  74. package/src/slack/render-v2.ts +10 -4
  75. package/src/slack/responses.ts +7 -4
  76. package/src/slack/task-output.ts +21 -0
  77. package/src/slack/watcher.ts +3 -2
  78. package/src/slack/work-objects.ts +2 -1
  79. package/src/tests/extensions-example-mcp-install.test.ts +2 -2
  80. package/src/tests/extensions-http.test.ts +84 -3
  81. package/src/tests/extensions-lead-equivalence.test.ts +21 -0
  82. package/src/tests/extensions-mcp-tools.test.ts +111 -3
  83. package/src/tests/fixtures/store-progress-malformed-output.json +4 -0
  84. package/src/tests/rbac-admission-e2e.test.ts +35 -4
  85. package/src/tests/rbac-e2e-helpers.ts +18 -0
  86. package/src/tests/rbac-engine.test.ts +2 -2
  87. package/src/tests/rbac-mcp-admission-e2e.test.ts +22 -4
  88. package/src/tests/reload-config.test.ts +17 -0
  89. package/src/tests/scripts-only-gating.test.ts +4 -0
  90. package/src/tests/slack-render-v2.test.ts +24 -2
  91. package/src/tests/slack-task-output.test.ts +36 -0
  92. package/src/tests/store-progress-attachments-handler.test.ts +100 -1
  93. package/src/tests/swarm-config-reserved-keys.test.ts +6 -1
  94. package/src/tests/tool-annotations.test.ts +1 -1
  95. package/src/tools/extension-activate-version.ts +38 -0
  96. package/src/tools/extension-common.ts +2 -1
  97. package/src/tools/extension-delete.ts +29 -0
  98. package/src/tools/extension-disable.ts +36 -0
  99. package/src/tools/extension-enable.ts +36 -0
  100. package/src/tools/extension-install.ts +2 -2
  101. package/src/tools/store-progress.ts +30 -0
  102. package/src/tools/tool-config.ts +5 -1
  103. package/templates/skills/agent-browser/SKILL.md +84 -0
  104. package/templates/skills/agent-browser/content.md +21 -0
@@ -227,6 +227,23 @@ describe("reload-config", () => {
227
227
  }
228
228
  });
229
229
 
230
+ test("stored lead activation gate cannot override the deployment at boot or reload", async () => {
231
+ const key = "EXTENSION_ALLOW_LEAD_ACTIVATION";
232
+ const original = process.env[key];
233
+ const rowId = await insertLegacyReservedRow(key, "true");
234
+ try {
235
+ process.env[key] = "false";
236
+ for (const override of [false, true]) {
237
+ expect(await loadGlobalConfigsIntoEnv(override)).not.toContain(key);
238
+ expect(process.env[key]).toBe("false");
239
+ }
240
+ } finally {
241
+ await deleteSwarmConfig(rowId);
242
+ if (original === undefined) delete process.env[key];
243
+ else process.env[key] = original;
244
+ }
245
+ });
246
+
230
247
  test("loadGlobalConfigsIntoEnv skips unreadable reserved secret rows before decrypting them", async () => {
231
248
  const id = await insertUnreadableReservedSecretRow("SECRETS_ENCRYPTION_KEY");
232
249
 
@@ -17,6 +17,10 @@ import { listenOnFreePort } from "./test-net";
17
17
 
18
18
  const TEST_DB_PATH = "./test-scripts-only-gating.sqlite";
19
19
  const SCRIPT_TOOL_NAMES = [
20
+ "extension-delete",
21
+ "extension-enable",
22
+ "extension-disable",
23
+ "extension-activate-version",
20
24
  "extension-install",
21
25
  "extension-list",
22
26
  "get-script-run",
@@ -1789,7 +1789,7 @@ describe("Slack renderer v2", () => {
1789
1789
  expect(outcome?.finalizedAt).toBeDefined();
1790
1790
  });
1791
1791
 
1792
- test("keeps a deferral's human-facing line even when the agent already sent a slack-reply", async () => {
1792
+ test("omits the deferral ETA even when the agent already sent a slack-reply", async () => {
1793
1793
  const lead = await createAgent({ name: "Deferring Lead", isLead: true, status: "idle" });
1794
1794
  const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_DEFER_AFTER_REPLY");
1795
1795
  const ask = await createTaskExtended("defer after replying by hand", {
@@ -1814,10 +1814,32 @@ describe("Slack renderer v2", () => {
1814
1814
  await processSlackRenderV2();
1815
1815
 
1816
1816
  const started = calls.find((call) => call.method === "chat.startStream");
1817
- expect(started?.payload.markdown_text).toBe(`✅\n\n${deferralLine}`);
1817
+ expect(started?.payload.markdown_text).toBe("✅\n\nPending: checking the new defer card");
1818
+ expect(JSON.stringify(calls)).not.toContain("17:30:19");
1819
+ expect(JSON.stringify(calls)).not.toContain("Deferred until");
1820
+ expect((await getTaskById(ask.id))?.output).toBe(deferralLine);
1818
1821
  expect(started?.payload.markdown_text).not.toBe(`✅ ${lead.name} completed`);
1819
1822
  });
1820
1823
 
1824
+ test("does not post an ETA-only deferral notice", async () => {
1825
+ const { channelId, threadTs } = uniqueSlackAddress("C_ETA_ONLY");
1826
+ const ask = await createTaskExtended("wait", {
1827
+ source: "slack",
1828
+ slackChannelId: channelId,
1829
+ slackThreadTs: threadTs,
1830
+ });
1831
+ await startTask(ask.id);
1832
+ await completeTask(
1833
+ ask.id,
1834
+ "Deferred until today 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> ",
1835
+ { addTags: ["deferred"] },
1836
+ );
1837
+ calls.length = 0;
1838
+ await processSlackRenderV2();
1839
+ expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
1840
+ expect(JSON.stringify(calls)).not.toContain("17:30");
1841
+ });
1842
+
1821
1843
  test("refreshes a stream started with stale content before finalizing it", async () => {
1822
1844
  const lead = await createAgent({ name: "Refresh Lead", isLead: true, status: "idle" });
1823
1845
  const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_REFRESH_STALE");
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { slackTaskOutput } from "../slack/task-output";
3
+
4
+ const task = {
5
+ status: "completed" as const,
6
+ tags: ["deferred"],
7
+ output:
8
+ "Deferred until tomorrow 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> check the build",
9
+ };
10
+
11
+ describe("Slack deferral output", () => {
12
+ test("keeps the pending work without the ETA or schedule link", () => {
13
+ expect(slackTaskOutput(task)).toBe("Pending: check the build");
14
+ expect(task.output).toContain("tomorrow 17:30 UTC");
15
+ });
16
+
17
+ test("removes an ETA-only line and preserves punctuation in the pending description", () => {
18
+ expect(slackTaskOutput({ ...task, output: task.output.replace("check the build", "") })).toBe(
19
+ "",
20
+ );
21
+ expect(slackTaskOutput({ ...task, output: `${task.output} (CI) -> deploy` })).toBe(
22
+ "Pending: check the build (CI) -> deploy",
23
+ );
24
+ });
25
+
26
+ test("preserves final continuation results even with inherited deferred tags", () => {
27
+ expect(slackTaskOutput({ ...task, output: "Build passed." })).toBe("Build passed.");
28
+ });
29
+
30
+ test("preserves schema output and non-deferral output verbatim", () => {
31
+ expect(slackTaskOutput({ ...task, outputSchema: { type: "string" } })).toBe(task.output);
32
+ expect(slackTaskOutput({ ...task, tags: [] })).toBe(task.output);
33
+ expect(slackTaskOutput({ ...task, status: "in_progress" })).toBe(task.output);
34
+ expect(slackTaskOutput({ ...task, output: undefined })).toBeUndefined();
35
+ });
36
+ });
@@ -17,7 +17,7 @@
17
17
  * org/drive and credentials
18
18
  * 4. missing files and wrong drives both block attachment + task writes
19
19
  */
20
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
20
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
21
21
  import crypto from "node:crypto";
22
22
  import { unlink } from "node:fs/promises";
23
23
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -34,6 +34,7 @@ import {
34
34
  upsertSwarmConfig,
35
35
  } from "../be/db";
36
36
  import { registerStoreProgressTool } from "../tools/store-progress";
37
+ import malformedOutput from "./fixtures/store-progress-malformed-output.json";
37
38
 
38
39
  const TEST_DB_PATH = "./test-store-progress-attachments-handler.sqlite";
39
40
  const ORIGINAL_FETCH = globalThis.fetch;
@@ -137,6 +138,104 @@ describe("store-progress handler — attachments insert path", () => {
137
138
  };
138
139
  }
139
140
 
141
+ test("salvages the literal 59811b57 output and registers both URL attachments", async () => {
142
+ const task = await createTaskExtended("salvage leaked tool parameters", { agentId });
143
+ await startTask(task.id);
144
+ const result = (await buildServer().handler(
145
+ { taskId: task.id, status: "completed", output: malformedOutput.output },
146
+ buildMeta(),
147
+ )) as StoreProgressResult;
148
+
149
+ expect(result.structuredContent.success).toBe(true);
150
+ const saved = await getTaskById(task.id);
151
+ expect(saved?.output).toBe(malformedOutput.output.split("</output>")[0]);
152
+ expect(saved?.output?.endsWith("reposted clean.")).toBe(true);
153
+ const attachments = await getTaskAttachments(task.id);
154
+ expect(attachments).toHaveLength(2);
155
+ expect(attachments.every((a) => a.kind === "url")).toBe(true);
156
+ expect(attachments.map((a) => a.url).sort()).toEqual([
157
+ "https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6",
158
+ "https://x.com/desplegalabs/status/2100595056939827590",
159
+ ]);
160
+ expect(attachments.find((a) => a.isPrimary)?.name).toBe("X reply in the Jev thread");
161
+ });
162
+
163
+ test("preserves clean output byte-for-byte", async () => {
164
+ const output = "The output is ready: 2 < 3.\n Keep this whitespace. ";
165
+ const task = await createTaskExtended("clean output control", { agentId });
166
+ await startTask(task.id);
167
+ const result = (await buildServer().handler(
168
+ { taskId: task.id, status: "completed", output },
169
+ buildMeta(),
170
+ )) as StoreProgressResult;
171
+ expect(result.structuredContent.success).toBe(true);
172
+ expect((await getTaskById(task.id))?.output).toBe(output);
173
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
174
+ });
175
+
176
+ test.each([
177
+ ["invalid JSON", '</output>\n<parameter name="attachments">[broken'],
178
+ ["invalid attachment schema", '</output><parameter name="attachments">[{"kind":"url"}]'],
179
+ ["unknown parameter", '</output><parameter name="status">"failed"'],
180
+ ["alternate closing tag", '</parameter>\n<parameter name="progress">Not JSON'],
181
+ ["bare opener", '<parameter name="progress">Not JSON'],
182
+ ["non-array JSON", '</output><parameter name="attachments">null'],
183
+ ])("strips %s without losing the clean result", async (_name, tail) => {
184
+ const task = await createTaskExtended("unrecoverable tail", { agentId });
185
+ await startTask(task.id);
186
+ const warning = spyOn(console, "warn").mockImplementation(() => {});
187
+ try {
188
+ const result = (await buildServer().handler(
189
+ { taskId: task.id, status: "completed", output: `Clean result.${tail}` },
190
+ buildMeta(),
191
+ )) as StoreProgressResult;
192
+ expect(result.structuredContent.success).toBe(true);
193
+ expect((await getTaskById(task.id))?.output).toBe("Clean result.");
194
+ expect((await getTaskById(task.id))?.status).toBe("completed");
195
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
196
+ expect(warning).toHaveBeenCalledTimes(1);
197
+ expect(warning.mock.calls[0]?.[0]).not.toContain(tail);
198
+ } finally {
199
+ warning.mockRestore();
200
+ }
201
+ });
202
+
203
+ test("appends recovered pointers to explicit attachments through the normal dedupe path", async () => {
204
+ const task = await createTaskExtended("mixed explicit and leaked attachments", { agentId });
205
+ await startTask(task.id);
206
+ const attachment = { kind: "url", name: "link", url: "https://example.com/link" };
207
+ const result = (await buildServer().handler(
208
+ {
209
+ taskId: task.id,
210
+ status: "completed",
211
+ output: `Done.<parameter name="attachments">${JSON.stringify([attachment])}</parameter>`,
212
+ attachments: [attachment],
213
+ },
214
+ buildMeta(),
215
+ )) as StoreProgressResult;
216
+ expect(result.structuredContent.success).toBe(true);
217
+ expect((await getTaskById(task.id))?.output).toBe("Done.");
218
+ expect(await getTaskAttachments(task.id)).toHaveLength(1);
219
+ });
220
+
221
+ test("recovered agent-fs pointers still require ordinary scope verification", async () => {
222
+ const task = await createTaskExtended("verify recovered pointer", { agentId });
223
+ await startTask(task.id);
224
+ const result = (await buildServer().handler(
225
+ {
226
+ taskId: task.id,
227
+ status: "completed",
228
+ output:
229
+ 'Done.</output><parameter name="attachments">[{"kind":"agent-fs","name":"missing.md","path":"/missing.md"}]',
230
+ },
231
+ buildMeta(),
232
+ )) as StoreProgressResult;
233
+ expect(result.structuredContent.success).toBe(false);
234
+ expect(result.structuredContent.message).toContain("both orgId and driveId must resolve");
235
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
236
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
237
+ });
238
+
140
239
  test("inserts attachment row on an in-progress task (baseline)", async () => {
141
240
  const task = await createTaskExtended("handler in-progress baseline", {
142
241
  agentId,
@@ -134,7 +134,12 @@ describe("swarm-config reserved keys guard", () => {
134
134
  await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
135
135
  });
136
136
 
137
- for (const key of ["CORS_ALLOW_ANY_ORIGIN", "cors_allow_any_origin"]) {
137
+ for (const key of [
138
+ "CORS_ALLOW_ANY_ORIGIN",
139
+ "cors_allow_any_origin",
140
+ "EXTENSION_ALLOW_LEAD_ACTIVATION",
141
+ "extension_allow_lead_activation",
142
+ ]) {
138
143
  test(`rejects deployment-only ${key} through DB, HTTP, and MCP`, async () => {
139
144
  const config = { scope: "global" as const, key, value: "true" };
140
145
  await expect(upsertSwarmConfig(config)).rejects.toThrow(EXPECTED_MESSAGE(key));
@@ -378,7 +378,7 @@ describe("Tool Annotations & Classification", () => {
378
378
  // Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
379
379
  // native Kapso/WhatsApp tools (register/unregister number + send/reply message).
380
380
  expect(count).toBeGreaterThanOrEqual(45);
381
- expect(count).toBeLessThanOrEqual(143);
381
+ expect(count).toBeLessThanOrEqual(147);
382
382
  });
383
383
 
384
384
  test("core tools are fewer than deferred tools", () => {
@@ -0,0 +1,38 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import {
5
+ coerceExtensionSummary,
6
+ extensionToolOutputSchema,
7
+ proxyExtensionsApi,
8
+ } from "./extension-common";
9
+
10
+ export const registerExtensionActivateVersionTool = (server: McpServer) => {
11
+ createToolRegistrar(server)(
12
+ "extension-activate-version",
13
+ {
14
+ title: "Extension Activate Version",
15
+ description:
16
+ "Activate a stored extension version, reloading it if enabled. Requires a lead, operator, or dashboard user.",
17
+ annotations: { openWorldHint: false },
18
+ inputSchema: z
19
+ .object({
20
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
21
+ version: z.number().int().min(1).describe("Stored version number to activate."),
22
+ })
23
+ .strict(),
24
+ outputSchema: extensionToolOutputSchema,
25
+ },
26
+ async (args, requestInfo) =>
27
+ proxyExtensionsApi({
28
+ method: "POST",
29
+ path: `/api/extensions/${args.id}/activate-version`,
30
+ body: { version: args.version },
31
+ requestInfo,
32
+ success: (data) =>
33
+ toolOk("Extension activated.", {
34
+ data: coerceExtensionSummary((data as { extension?: unknown }).extension),
35
+ }),
36
+ }),
37
+ );
38
+ };
@@ -26,6 +26,7 @@ export const extensionToolOutputSchema = swarmToolOutputSchema({
26
26
  priority: z.number().optional(),
27
27
  consecutiveFailures: z.number().optional(),
28
28
  contentDeduped: z.boolean().optional(),
29
+ deleted: z.boolean().optional(),
29
30
  extensions: z.array(extensionListItemSchema).optional(),
30
31
  });
31
32
 
@@ -61,7 +62,7 @@ function renderDiagnostics(data: unknown): string | undefined {
61
62
  }
62
63
 
63
64
  export async function proxyExtensionsApi<T>(args: {
64
- method: "GET" | "POST";
65
+ method: "GET" | "POST" | "DELETE";
65
66
  path: string;
66
67
  body?: unknown;
67
68
  requestInfo: RequestInfo;
@@ -0,0 +1,29 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import { extensionToolOutputSchema, proxyExtensionsApi } from "./extension-common";
5
+
6
+ export const registerExtensionDeleteTool = (server: McpServer) => {
7
+ createToolRegistrar(server)(
8
+ "extension-delete",
9
+ {
10
+ title: "Extension Delete",
11
+ description:
12
+ "Uninstall a disabled extension and delete its stored history. Disable it first with extension-disable. Requires a lead, operator, or dashboard user.",
13
+ annotations: { openWorldHint: false, destructiveHint: true },
14
+ inputSchema: z
15
+ .object({
16
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
17
+ })
18
+ .strict(),
19
+ outputSchema: extensionToolOutputSchema,
20
+ },
21
+ async (args, requestInfo) =>
22
+ proxyExtensionsApi({
23
+ method: "DELETE",
24
+ path: `/api/extensions/${args.id}`,
25
+ requestInfo,
26
+ success: () => toolOk("Extension deleted.", { data: { id: args.id, deleted: true } }),
27
+ }),
28
+ );
29
+ };
@@ -0,0 +1,36 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import {
5
+ coerceExtensionSummary,
6
+ extensionToolOutputSchema,
7
+ proxyExtensionsApi,
8
+ } from "./extension-common";
9
+
10
+ export const registerExtensionDisableTool = (server: McpServer) => {
11
+ createToolRegistrar(server)(
12
+ "extension-disable",
13
+ {
14
+ title: "Extension Disable",
15
+ description:
16
+ "Unload and disable an installed extension. Requires a lead, operator, or dashboard user.",
17
+ annotations: { openWorldHint: false },
18
+ inputSchema: z
19
+ .object({
20
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
21
+ })
22
+ .strict(),
23
+ outputSchema: extensionToolOutputSchema,
24
+ },
25
+ async (args, requestInfo) =>
26
+ proxyExtensionsApi({
27
+ method: "POST",
28
+ path: `/api/extensions/${args.id}/disable`,
29
+ requestInfo,
30
+ success: (data) =>
31
+ toolOk("Extension disabled.", {
32
+ data: coerceExtensionSummary((data as { extension?: unknown }).extension),
33
+ }),
34
+ }),
35
+ );
36
+ };
@@ -0,0 +1,36 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import {
5
+ coerceExtensionSummary,
6
+ extensionToolOutputSchema,
7
+ proxyExtensionsApi,
8
+ } from "./extension-common";
9
+
10
+ export const registerExtensionEnableTool = (server: McpServer) => {
11
+ createToolRegistrar(server)(
12
+ "extension-enable",
13
+ {
14
+ title: "Extension Enable",
15
+ description:
16
+ "Load and enable an installed extension. Requires a lead, operator, or dashboard user.",
17
+ annotations: { openWorldHint: false },
18
+ inputSchema: z
19
+ .object({
20
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
21
+ })
22
+ .strict(),
23
+ outputSchema: extensionToolOutputSchema,
24
+ },
25
+ async (args, requestInfo) =>
26
+ proxyExtensionsApi({
27
+ method: "POST",
28
+ path: `/api/extensions/${args.id}/enable`,
29
+ requestInfo,
30
+ success: (data) =>
31
+ toolOk("Extension enabled.", {
32
+ data: coerceExtensionSummary((data as { extension?: unknown }).extension),
33
+ }),
34
+ }),
35
+ );
36
+ };
@@ -9,7 +9,7 @@ import {
9
9
  } from "./extension-common";
10
10
 
11
11
  export const EXTENSION_INSTALL_DESCRIPTION =
12
- "Validate and store a TypeScript extension bundle (manifest plus a files map with one hooks.ts). Fetch the hook contract first: GET /api/extensions/type-defs returns swarm-extension.d.ts with every event and modify shape. The extension remains disabled until an operator enables it.";
12
+ "Validate and store a TypeScript extension bundle (manifest plus a files map with one hooks.ts). Fetch the hook contract first: GET /api/extensions/type-defs returns swarm-extension.d.ts with every event and modify shape. The extension remains disabled until a lead, operator, or dashboard user enables it with extension-enable.";
13
13
 
14
14
  function extensionInstallResult(data: unknown, fallbackName: string) {
15
15
  const body = data as {
@@ -21,7 +21,7 @@ function extensionInstallResult(data: unknown, fallbackName: string) {
21
21
  const version = typeof extension.version === "number" ? ` v${extension.version}` : "";
22
22
  const deduped = body.contentDeduped === true ? " Content was unchanged." : "";
23
23
  const enableInstruction =
24
- "An operator must enable it from the dashboard or `POST /api/extensions/{id}/enable`.";
24
+ "A lead, operator, or dashboard user can enable it with `extension-enable` or `POST /api/extensions/{id}/enable`.";
25
25
 
26
26
  return toolOk(`Extension \`${name}\`${version} installed.${deduped} ${enableInstruction}`, {
27
27
  data: {
@@ -167,6 +167,36 @@ export const registerStoreProgressTool = (server: McpServer) => {
167
167
  ? requestedStatus
168
168
  : undefined;
169
169
 
170
+ // Stock Claude occasionally puts the next tool parameter inside output.
171
+ // Strip the leaked tail before validation/persistence; recovered pointers
172
+ // must still pass the ordinary attachment schema and verification below.
173
+ const leakedParameter = output?.match(
174
+ /(?:<\/(?:output|parameter)>\s*)?<parameter name="([a-zA-Z_]+)">\s*([\s\S]*)$/,
175
+ );
176
+ if (leakedParameter) {
177
+ output = output?.slice(0, leakedParameter.index);
178
+ let recovered = false;
179
+ if (leakedParameter[1] === "attachments") {
180
+ try {
181
+ const parsed = z
182
+ .array(AttachmentInputSchema)
183
+ .max(20)
184
+ .safeParse(JSON.parse(leakedParameter[2]!.replace(/\s*<\/parameter>\s*$/, "")));
185
+ if (parsed.success && (attachments?.length ?? 0) + parsed.data.length <= 20) {
186
+ attachments = [...(attachments ?? []), ...parsed.data];
187
+ recovered = true;
188
+ }
189
+ } catch {
190
+ // Malformed JSON must not prevent saving the cleaned task result.
191
+ }
192
+ }
193
+ if (!recovered) {
194
+ console.warn(
195
+ `[store-progress] Stripped malformed output tail for task ${taskId}; parameter ${leakedParameter[1]} was not recovered.`,
196
+ );
197
+ }
198
+ }
199
+
170
200
  // Verify agent-fs pointers before opening the write transaction. The
171
201
  // registering agent's resolved config selects both credentials and the
172
202
  // exact org/drive; never let the provider fall back to a personal drive.
@@ -197,13 +197,17 @@ export const DEFERRED_TOOLS = new Set([
197
197
  "room-reset",
198
198
  "room-decode",
199
199
 
200
- // Reusable scripts and extensions (8)
200
+ // Reusable scripts and extensions (12)
201
201
  "script-upsert",
202
202
  "script-delete",
203
203
  "script-apis",
204
204
  "launch-script-run",
205
205
  "get-script-run",
206
206
  "list-script-runs",
207
+ "extension-delete",
208
+ "extension-enable",
209
+ "extension-disable",
210
+ "extension-activate-version",
207
211
  "extension-install",
208
212
  "extension-list",
209
213
 
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: agent-browser
3
+ description: "Drive a real browser with the agent-browser CLI (headless Chromium over CDP). Use for any browser task: verifying a UI change, taking a screenshot, walking a web page, filling a form, extracting page text, reproducing a frontend bug, or attaching UI evidence to a PR, issue, Linear comment, or Slack thread. Starts with a `command -v agent-browser` check and falls back to manual QA on the slim worker image. Screenshots are shared through agent-fs signed URLs. This is the only browser automation tool; do not use qa-use."
4
+ ---
5
+
6
+ # agent-browser
7
+
8
+ `agent-browser` (vercel-labs) is the swarm's browser-automation CLI. It drives a headless Chromium over CDP and returns accessibility-tree snapshots with compact `@eN` refs. Use it for any browser task: verify a UI change, take a screenshot, walk a page, fill a form, extract page text, reproduce a frontend bug, or attach UI evidence to a PR, issue, Linear comment, or Slack thread.
9
+
10
+ Do not use `qa-use`. It is not installed in the worker image any more.
11
+
12
+ ## Step 0: check that the CLI is present
13
+
14
+ The full worker image (`agent-swarm-worker:latest`) ships `agent-browser` and a headless Chromium. The slim image (`:slim`) has neither. The first command of any browser work is:
15
+
16
+ ```bash
17
+ command -v agent-browser
18
+ ```
19
+
20
+ If it prints nothing, you are on the slim image. Say so in your progress report ("agent-browser is not available on this worker image") and fall back to manual QA: write the exact steps a human should run and mark the browser test cases as **Blocked**, not failed. Do not install a browser or the CLI yourself.
21
+
22
+ ## Step 1: load the version-matched guide
23
+
24
+ The CLI bundles its own usage guide. Load it once per task before the first browser command. Do not guess flags from memory.
25
+
26
+ ```bash
27
+ agent-browser skills get core # overview + common patterns
28
+ agent-browser skills get core --full # adds the full command reference
29
+ agent-browser skills list # specialized guides (dogfood, electron, slack, ...)
30
+ ```
31
+
32
+ ## Step 2: the core loop
33
+
34
+ ```bash
35
+ agent-browser open <url>
36
+ agent-browser snapshot -i # accessibility tree, interactive elements only, @eN refs
37
+ agent-browser click @e12 # act on refs from the snapshot
38
+ agent-browser fill @e7 "text"
39
+ agent-browser screenshot /tmp/<name>.png
40
+ agent-browser close
41
+ ```
42
+
43
+ - Re-run `snapshot` after every navigation or state change. Refs are only valid for the snapshot that produced them.
44
+ - Run one browser session at a time. Call `store-progress` between page batches so the heartbeat watchdog sees you.
45
+ - The browser is headless. Local servers inside the worker container are reachable at `http://localhost:<port>`.
46
+ - The image launches Chromium with `--no-sandbox,--disable-dev-shm-usage` through `AGENT_BROWSER_ARGS`. If you pass your own `--args`, include `--no-sandbox` again or Chrome will not start.
47
+
48
+ ## Recording
49
+
50
+ Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
51
+
52
+ Open the page first, then record the interaction:
53
+
54
+ ```bash
55
+ agent-browser record start ./in.mp4 --cursor
56
+ agent-browser snapshot -i
57
+ agent-browser mouse move 600 400 --human --seed 42 --duration 1400
58
+ agent-browser click @e3 --human # use a ref from the current snapshot
59
+ # Perform the remaining QA steps; use --human for drag actions too.
60
+ agent-browser record stop
61
+ ffmpeg -y -i in.mp4 -filter:v "setpts=PTS/1.5,fps=30" -an \
62
+ -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p out-1.5x.mp4
63
+ ```
64
+
65
+ Speed-up is a post-process step: `record start` has no speed or human flag. Keep `fps=30` to preserve a steady 30 fps output rather than uneven frame drops at an inferred rate. `-an` disables audio because agent-browser recordings carry no audio track. The worker ships only `ffmpeg`, not `ffprobe`; inspect duration with `ffmpeg -i out-1.5x.mp4 2>&1 | grep Duration`.
66
+
67
+ Add `--contact-sheet` to the start command for a timestamped PNG summary of visual changes. Recording needs `ffmpeg` on PATH; check `agent-browser doctor` before starting. Stop the recording before closing the browser so the video is saved. Upload the 1.5x file through the same agent-fs QA path and signed-URL steps as screenshots below, and link it in the PR body.
68
+
69
+ ## Step 3: share the screenshot through agent-fs
70
+
71
+ Screenshots on the worker disk disappear with the task. Upload them to agent-fs under the qa path convention and share the signed URL. `--file` is binary-safe. `--content` is text-only and mangles PNGs.
72
+
73
+ ```bash
74
+ agent-fs write thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png \
75
+ --file /tmp/<name>.png -m "<what it shows>"
76
+ agent-fs stat thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png --json # confirm size > 0
77
+ agent-fs signed-url thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png --json # 24h default, --expires-in up to 7d
78
+ ```
79
+
80
+ - Embed the URL as `![caption](<url>)` in the PR body, review comment, Linear comment, or Slack message.
81
+ - In `store-progress`, list the upload in the `attachments` field with `kind: "agent-fs"` and the path, and paste the signed URL in the progress text.
82
+ - If `agent-fs auth whoami` fails, report the local path, say the upload was skipped, and continue.
83
+
84
+ The `artifacts` skill holds the full agent-fs recipe and the naming conventions.
@@ -40,6 +40,27 @@ agent-browser close
40
40
  - The browser is headless. Local servers inside the worker container are reachable at `http://localhost:<port>`.
41
41
  - The image launches Chromium with `--no-sandbox,--disable-dev-shm-usage` through `AGENT_BROWSER_ARGS`. If you pass your own `--args`, include `--no-sandbox` again or Chrome will not start.
42
42
 
43
+ ## Recording
44
+
45
+ Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
46
+
47
+ Open the page first, then record the interaction:
48
+
49
+ ```bash
50
+ agent-browser record start ./in.mp4 --cursor
51
+ agent-browser snapshot -i
52
+ agent-browser mouse move 600 400 --human --seed 42 --duration 1400
53
+ agent-browser click @e3 --human # use a ref from the current snapshot
54
+ # Perform the remaining QA steps; use --human for drag actions too.
55
+ agent-browser record stop
56
+ ffmpeg -y -i in.mp4 -filter:v "setpts=PTS/1.5,fps=30" -an \
57
+ -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p out-1.5x.mp4
58
+ ```
59
+
60
+ Speed-up is a post-process step: `record start` has no speed or human flag. Keep `fps=30` to preserve a steady 30 fps output rather than uneven frame drops at an inferred rate. `-an` disables audio because agent-browser recordings carry no audio track. The worker ships only `ffmpeg`, not `ffprobe`; inspect duration with `ffmpeg -i out-1.5x.mp4 2>&1 | grep Duration`.
61
+
62
+ Add `--contact-sheet` to the start command for a timestamped PNG summary of visual changes. Recording needs `ffmpeg` on PATH; check `agent-browser doctor` before starting. Stop the recording before closing the browser so the video is saved. Upload the 1.5x file through the same agent-fs QA path and signed-URL steps as screenshots below, and link it in the PR body.
63
+
43
64
  ## Step 3: share the screenshot through agent-fs
44
65
 
45
66
  Screenshots on the worker disk disappear with the task. Upload them to agent-fs under the qa path convention and share the signed URL. `--file` is binary-safe. `--content` is text-only and mangles PNGs.