@desplega.ai/agent-swarm 1.98.0 → 1.99.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 (43) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +20 -1
  3. package/package.json +5 -5
  4. package/src/be/memory/link-resolver.ts +226 -0
  5. package/src/be/memory/providers/sqlite-store.ts +4 -2
  6. package/src/be/memory/raters/retrieval.ts +15 -4
  7. package/src/be/memory/raters/store.ts +4 -2
  8. package/src/be/memory/types.ts +1 -0
  9. package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
  10. package/src/be/modelsdev-cache.ts +5 -0
  11. package/src/be/pricing-refresh.ts +189 -0
  12. package/src/be/scripts/typecheck.ts +3 -2
  13. package/src/be/seed-pricing.ts +5 -3
  14. package/src/commands/profile-sync.ts +83 -17
  15. package/src/commands/runner.ts +35 -3
  16. package/src/e2b/dispatch.ts +5 -0
  17. package/src/hooks/hook.ts +21 -5
  18. package/src/http/index.ts +2 -0
  19. package/src/http/memory.ts +116 -7
  20. package/src/providers/claude-adapter.ts +13 -2
  21. package/src/providers/pricing-sources.md +27 -9
  22. package/src/providers/types.ts +1 -0
  23. package/src/scripts-runtime/swarm-sdk.ts +5 -1
  24. package/src/scripts-runtime/types/stdlib.d.ts +2 -1
  25. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
  26. package/src/server.ts +2 -0
  27. package/src/slack/blocks.ts +58 -12
  28. package/src/slack/responses.ts +35 -12
  29. package/src/slack/watcher.ts +28 -7
  30. package/src/tests/internal-ai/complete-structured.test.ts +34 -1
  31. package/src/tests/memory-http-recall-gating.test.ts +172 -0
  32. package/src/tests/memory-link-resolver.test.ts +92 -0
  33. package/src/tests/opencode-adapter.test.ts +3 -0
  34. package/src/tests/pricing-refresh.test.ts +156 -0
  35. package/src/tests/profile-sync.test.ts +186 -0
  36. package/src/tests/scripts-mcp-e2e.test.ts +1 -1
  37. package/src/tests/slack-blocks.test.ts +48 -1
  38. package/src/tools/memory-get.ts +22 -1
  39. package/src/tools/memory-search.ts +8 -1
  40. package/src/tools/utils.ts +10 -0
  41. package/src/types.ts +2 -0
  42. package/src/utils/internal-ai/complete-structured.ts +10 -1
  43. package/tsconfig.json +1 -0
@@ -248,6 +248,7 @@ export function mergeMcpConfig(
248
248
  baseConfig: { mcpServers?: Record<string, unknown> } | null,
249
249
  installedServers: Record<string, Record<string, unknown>> | null,
250
250
  taskId: string,
251
+ contextKey?: string,
251
252
  ): { mcpServers: Record<string, unknown> } {
252
253
  const config: { mcpServers: Record<string, unknown> } = {
253
254
  mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
@@ -273,6 +274,9 @@ export function mergeMcpConfig(
273
274
  const server = config.mcpServers[serverKey] as Record<string, unknown>;
274
275
  if (!server.headers) server.headers = {};
275
276
  (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
277
+ if (contextKey) {
278
+ (server.headers as Record<string, string>)["X-Context-Key"] = contextKey;
279
+ }
276
280
  }
277
281
 
278
282
  return config;
@@ -291,6 +295,7 @@ export async function createSessionMcpConfig(
291
295
  cwd: string,
292
296
  taskId: string,
293
297
  installedServers?: Record<string, Record<string, unknown>> | null,
298
+ contextKey?: string,
294
299
  ): Promise<string | null> {
295
300
  // Collect every .mcp.json from cwd up to filesystem root. Stopping at the first
296
301
  // match silently drops the swarm-managed /workspace/.mcp.json when the cloned
@@ -341,7 +346,12 @@ export async function createSessionMcpConfig(
341
346
  }
342
347
 
343
348
  try {
344
- const config = mergeMcpConfig({ mcpServers: mergedServers }, installedServers ?? null, taskId);
349
+ const config = mergeMcpConfig(
350
+ { mcpServers: mergedServers },
351
+ installedServers ?? null,
352
+ taskId,
353
+ contextKey,
354
+ );
345
355
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
346
356
  await writeFile(sessionConfigPath, JSON.stringify(config, null, 2));
347
357
  return sessionConfigPath;
@@ -950,11 +960,12 @@ export class ClaudeAdapter implements ProviderAdapter {
950
960
  );
951
961
  }
952
962
 
953
- // Create per-session MCP config with X-Source-Task-Id header + installed servers (no shared-file race condition)
963
+ // Create per-session MCP config with X-Source-Task-Id + X-Context-Key headers + installed servers (no shared-file race condition)
954
964
  const sessionMcpConfig = await createSessionMcpConfig(
955
965
  config.cwd,
956
966
  config.taskId,
957
967
  installedServers,
968
+ config.contextKey,
958
969
  );
959
970
 
960
971
  // Stage the system prompt on disk so it can be passed as a file path
@@ -1,16 +1,32 @@
1
1
  # Pricing sources
2
2
 
3
- This page lists the sources that feed the `pricing` table at server boot.
4
- Operators bumping a rate by hand should also update this file.
3
+ This page lists the sources that feed the `pricing` table. Operators bumping a
4
+ rate by hand should also update this file.
5
5
 
6
- ## Primary: vendored models.dev snapshot
6
+ ## Primary pricing freshness: runtime models.dev refresh
7
7
 
8
- - **Source-of-truth path**: `src/be/modelsdev-cache.json`
8
+ - **Runtime module**: `src/be/pricing-refresh.ts`
9
+ - **Upstream**: `https://models.dev/api.json`, fetched with `If-None-Match`.
10
+ - **Boot wiring**: after `seedPricingFromModelsDev()`, the API server starts one
11
+ non-blocking refresh and then repeats every 12 hours with `setInterval`.
12
+ - **Update rule**: project upstream through `buildModelsDevSeedRows()` and insert
13
+ a new `effective_from=Date.now()` row only when the model/token class is new
14
+ or the active price changed. Identical prices are no-ops.
15
+ - **Growth bound**: after each refresh, keep only the latest two rows per
16
+ `(provider, model, token_class)` triple.
17
+ - **Pinned local entries**: safe by construction. The runtime refresh only adds
18
+ pricing rows; it does not rewrite or delete the committed snapshot.
19
+
20
+ ## Fallback/UI catalog: vendored models.dev snapshot
21
+
22
+ - **Fallback path**: `src/be/modelsdev-cache.json`
9
23
  - **UI compatibility path**: `ui/src/lib/modelsdev-cache.json` symlinks to the
10
24
  backend snapshot so existing UI imports keep working.
11
25
  - **Loaded by**: `src/be/modelsdev-cache.ts` → `src/be/seed-pricing.ts` →
12
26
  `seedPricingFromModelsDev()`,
13
27
  called from `src/server.ts` after `initDb`.
28
+ - **Role**: cold-start fallback seed for pricing when models.dev is unavailable,
29
+ plus the UI model-picker source for names, labels, and context windows.
14
30
  - **Projection rules** (see the same module for code-level detail):
15
31
  - Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
16
32
  Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
@@ -22,12 +38,13 @@ Operators bumping a rate by hand should also update this file.
22
38
  stripped name and the full `google/...` id) so internal-ai callers find
23
39
  a hit either way.
24
40
 
25
- - **Refresh procedure** (the only place to update the snapshot):
41
+ - **Snapshot refresh procedure**:
26
42
  - Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
27
43
  script). It fetches the latest snapshot from models.dev, diffs against
28
44
  the vendored copy, prints a summary, and writes the new file.
29
45
  - Commit the regenerated `src/be/modelsdev-cache.json` together with a bump
30
- note in the PR description.
46
+ note in the PR description. This is no longer the pricing freshness path;
47
+ use it when the fallback/UI catalog needs new labels or context-window data.
31
48
 
32
49
  ## Manual overrides
33
50
 
@@ -50,6 +67,7 @@ no input/output pricing rows at the lookup time, the row is persisted with
50
67
  `costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
51
68
  yellow badge.
52
69
 
53
- To fix: either add the model to `src/be/modelsdev-cache.json` (preferred the
54
- upstream snapshot probably needs refreshing) or add a manual override row via
55
- the existing admin route `POST /api/pricing`.
70
+ To fix: first check whether the runtime refresh is failing. If the model must
71
+ also appear in the UI picker or cold-start fallback, add it to
72
+ `src/be/modelsdev-cache.json`; otherwise add a manual override row via the
73
+ existing admin route `POST /api/pricing`.
@@ -92,6 +92,7 @@ export interface ProviderSessionConfig {
92
92
  apiKey: string;
93
93
  cwd: string;
94
94
  vcsRepo?: string;
95
+ contextKey?: string;
95
96
  /**
96
97
  * @deprecated Never set by the runner — native session resume was removed in
97
98
  * the 2026-05-28 plan. Adapters log + ignore any stray value. Follow-up
@@ -56,7 +56,11 @@ function bridgeRequestFor(name: string, args: unknown): BridgeRequest | null {
56
56
  case "memory_get": {
57
57
  const memoryId = typeof body.memoryId === "string" ? body.memoryId : undefined;
58
58
  if (!memoryId) throw new Error("memory_get requires string `memoryId`");
59
- return { method: "GET", path: `/api/memory/${encodeURIComponent(memoryId)}` };
59
+ const getIntent = typeof body.intent === "string" ? body.intent : "script-sdk";
60
+ return {
61
+ method: "GET",
62
+ path: `/api/memory/${encodeURIComponent(memoryId)}?intent=${encodeURIComponent(getIntent)}`,
63
+ };
60
64
  }
61
65
  case "memory_rate": {
62
66
  const event = {
@@ -50,11 +50,12 @@ declare module "swarm-sdk" {
50
50
  // --- memory ---
51
51
  memory_search(args: {
52
52
  query: string;
53
+ intent: string;
53
54
  scope?: "all" | "agent" | "swarm";
54
55
  limit?: number;
55
56
  source?: string;
56
57
  }): Promise<unknown>;
57
- memory_get(args: { memoryId: string }): Promise<unknown>;
58
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
58
59
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
59
60
  // --- tasks ---
60
61
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -32,11 +32,12 @@ declare module "swarm-sdk" {
32
32
  // --- memory ---
33
33
  memory_search(args: {
34
34
  query: string;
35
+ intent: string;
35
36
  scope?: "all" | "agent" | "swarm";
36
37
  limit?: number;
37
38
  source?: string;
38
39
  }): Promise<unknown>;
39
- memory_get(args: { memoryId: string }): Promise<unknown>;
40
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
40
41
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
41
42
  // --- tasks ---
42
43
  task_list(args?: Record<string, unknown>): Promise<unknown>;
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import pkg from "../package.json";
3
3
  import { initDb } from "./be/db";
4
+ import { startPricingRefreshLoop } from "./be/pricing-refresh";
4
5
  import { seedPricingFromModelsDev } from "./be/seed-pricing";
5
6
  import { registerCancelTaskTool } from "./tools/cancel-task";
6
7
  import { registerContextDiffTool } from "./tools/context-diff";
@@ -172,6 +173,7 @@ export function createServer() {
172
173
  // call on every boot. See src/be/seed-pricing.ts for the projection logic
173
174
  // and the manual-override constants for runtime-fee / ACU pricing.
174
175
  seedPricingFromModelsDev();
176
+ startPricingRefreshLoop();
175
177
 
176
178
  const server = new McpServer(
177
179
  {
@@ -8,8 +8,9 @@
8
8
  import type { AgentTaskStatus, TaskAttachment } from "../types";
9
9
  import { buildAgentFsLiveUrl, getAppUrl } from "../utils/constants";
10
10
 
11
- // Slack limits section text to 3000 chars; we use 2900 for safety
12
- const MAX_SECTION_LENGTH = 2900;
11
+ // Slack limits section text to 3000 chars; we use 2900 for safety.
12
+ export const MAX_SECTION_LENGTH = 2900;
13
+ export const MAX_BLOCKS_PER_COMPLETION_MESSAGE = 45;
13
14
 
14
15
  // biome-ignore lint/suspicious/noExplicitAny: Slack block types are complex unions; we build plain objects
15
16
  type SlackBlock = any;
@@ -38,24 +39,33 @@ export function getTaskUrl(taskId: string): string {
38
39
  * Convert GitHub-flavored markdown to Slack mrkdwn format.
39
40
  *
40
41
  * Key differences:
41
- * - GitHub: **bold**, *italic*, ~~strike~~, [text](url)
42
- * - Slack: *bold*, _italic_, ~strike~, <url|text>
42
+ * - GitHub: **bold**, __bold__, *italic*, ~~strike~~, ### Header, [text](url)
43
+ * - Slack: *bold*, *bold*, _italic_, ~strike~, *Header*, text (url)
43
44
  */
44
45
  export function markdownToSlack(text: string): string {
45
46
  return (
46
47
  text
48
+ // Images: keep alt text and expose the URL plainly.
49
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, url) =>
50
+ alt ? `${alt} (${url})` : url,
51
+ )
52
+ // Links: keep a plain URL fallback instead of Slack's <url|text> shortcut.
53
+ // Slack block auto-promotion has historically rejected that shortcut with
54
+ // invalid_blocks, while plain URLs remain copyable and auto-unfurlable.
55
+ .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, "$1 ($2)")
47
56
  // Headers to bold placeholder (# Header -> bold, protected from italic)
48
57
  .replace(/^#{1,6}\s+(.+)$/gm, "\uE000$1\uE001")
49
58
  // Bold **text** -> placeholder (to avoid italic chain converting *bold* to _italic_)
50
59
  .replace(/\*\*(.+?)\*\*/g, "\uE000$1\uE001")
60
+ // Bold __text__ -> placeholder
61
+ .replace(/__(.+?)__/g, "\uE000$1\uE001")
51
62
  // Italic *text* -> _text_ (single asterisks, now safe from bold placeholders)
52
63
  .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_")
64
+ // Italic _text_ already matches Slack mrkdwn; leave it alone.
53
65
  // Restore bold from placeholder -> *text*
54
66
  .replace(/\uE000(.+?)\uE001/g, "*$1*")
55
67
  // Strikethrough ~~text~~ -> ~text~
56
68
  .replace(/~~(.+?)~~/g, "~$1~")
57
- // Links [text](url) -> <url|text>
58
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
59
69
  // Inline code already works the same
60
70
  // Bullet points already work the same
61
71
  // Remove excessive blank lines
@@ -66,7 +76,7 @@ export function markdownToSlack(text: string): string {
66
76
  /**
67
77
  * Split text into chunks that fit within Slack's section text limit.
68
78
  */
69
- function splitText(text: string): string[] {
79
+ export function splitSlackSectionText(text: string): string[] {
70
80
  if (text.length <= MAX_SECTION_LENGTH) return [text];
71
81
 
72
82
  const chunks: string[] = [];
@@ -248,17 +258,50 @@ export function buildCompletedBlocks(opts: {
248
258
 
249
259
  // Only include body if not minimal (agent didn't reply via slack-reply)
250
260
  if (!opts.minimal) {
251
- for (const chunk of splitText(opts.body)) {
261
+ for (const chunk of splitSlackSectionText(opts.body)) {
252
262
  blocks.push(sectionBlock(chunk));
253
263
  }
254
264
  } else if (opts.trailer && opts.trailer.length > 0) {
255
- for (const chunk of splitText(opts.trailer)) {
265
+ for (const chunk of splitSlackSectionText(opts.trailer)) {
256
266
  blocks.push(sectionBlock(chunk));
257
267
  }
258
268
  }
259
269
  return blocks;
260
270
  }
261
271
 
272
+ /**
273
+ * Build one or more completed-task block payloads. The first payload carries
274
+ * the normal completion header; continuation payloads carry a compact part
275
+ * header. This keeps long completion summaries inside Slack's block limits
276
+ * without dropping body text.
277
+ */
278
+ export function buildCompletedBlockBatches(opts: Parameters<typeof buildCompletedBlocks>[0]) {
279
+ const allBlocks = buildCompletedBlocks(opts);
280
+ if (allBlocks.length <= MAX_BLOCKS_PER_COMPLETION_MESSAGE) return [allBlocks];
281
+
282
+ const header = allBlocks[0];
283
+ const bodyBlocks = allBlocks.slice(1);
284
+ const bodyLimit = MAX_BLOCKS_PER_COMPLETION_MESSAGE - 1;
285
+ const batches: SlackBlock[][] = [];
286
+
287
+ for (let start = 0; start < bodyBlocks.length; start += bodyLimit) {
288
+ const partBlocks = bodyBlocks.slice(start, start + bodyLimit);
289
+ if (start === 0) {
290
+ batches.push([header, ...partBlocks]);
291
+ } else {
292
+ const part = batches.length + 1;
293
+ batches.push([
294
+ sectionBlock(
295
+ `↳ *${opts.agentName}* (${getTaskLink(opts.taskId)}) continued · part ${part}`,
296
+ ),
297
+ ...partBlocks,
298
+ ]);
299
+ }
300
+ }
301
+
302
+ return batches;
303
+ }
304
+
262
305
  /**
263
306
  * Build blocks for a failed task response.
264
307
  * Single-line header, then error in code block.
@@ -369,7 +412,10 @@ function truncateOutput(text: string): string {
369
412
  const sentenceEnd = text.search(/\.\s/);
370
413
  const firstSentence = sentenceEnd !== -1 ? text.slice(0, sentenceEnd + 1) : text;
371
414
  if (firstSentence.length <= MAX_OUTPUT_LENGTH) return firstSentence;
372
- return `${text.slice(0, MAX_OUTPUT_LENGTH)}…`;
415
+ const boundary = text.lastIndexOf(" ", MAX_OUTPUT_LENGTH);
416
+ const cut = boundary >= MAX_OUTPUT_LENGTH / 2 ? boundary : MAX_OUTPUT_LENGTH;
417
+ const omitted = text.length - cut;
418
+ return `${text.slice(0, cut).trimEnd()}… (${omitted} more chars; full output in thread)`;
373
419
  }
374
420
 
375
421
  /**
@@ -399,7 +445,7 @@ function renderChildDetail(node: TreeNode, indent: string): string[] {
399
445
  }
400
446
 
401
447
  if (node.status === "completed" && !node.slackReplySent && node.output) {
402
- lines.push(`${indent}${truncateOutput(node.output)}`);
448
+ lines.push(`${indent}${truncateOutput(markdownToSlack(node.output))}`);
403
449
  }
404
450
 
405
451
  return lines;
@@ -423,7 +469,7 @@ function renderTree(root: TreeNode): string {
423
469
  lines.push(` Error: ${root.failureReason}`);
424
470
  }
425
471
  if (root.status === "completed" && !root.slackReplySent && root.output) {
426
- lines.push(` ${truncateOutput(root.output)}`);
472
+ lines.push(` ${truncateOutput(markdownToSlack(root.output))}`);
427
473
  }
428
474
  return lines.join("\n");
429
475
  }
@@ -4,6 +4,7 @@ import type { Agent, AgentTask } from "../types";
4
4
  import { getSlackApp } from "./app";
5
5
  import {
6
6
  buildCancelledBlocks,
7
+ buildCompletedBlockBatches,
7
8
  buildCompletedBlocks,
8
9
  buildFailedBlocks,
9
10
  buildProgressBlocks,
@@ -74,7 +75,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
74
75
  console.log(
75
76
  `[Slack] sendTaskResponse: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
76
77
  );
77
- const blocks = buildCompletedBlocks({
78
+ const completionOpts = {
78
79
  agentName,
79
80
  taskId: task.id,
80
81
  body,
@@ -84,15 +85,21 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
84
85
  // trailing addendum so links are visible without expanding the card.
85
86
  minimal: !!task.slackReplySent,
86
87
  trailer: task.slackReplySent ? attachmentsBlock : undefined,
87
- });
88
- await sendWithPersona(client, {
89
- channel: task.slackChannelId,
90
- thread_ts: task.slackThreadTs,
91
- text: task.slackReplySent ? `✅ ${agentName} completed` : body,
92
- username: getAgentDisplayName(agent),
93
- icon_emoji: getAgentEmoji(agent),
94
- blocks,
95
- });
88
+ };
89
+ const blockBatches = buildCompletedBlockBatches(completionOpts);
90
+ for (let i = 0; i < blockBatches.length; i++) {
91
+ await sendWithPersona(client, {
92
+ channel: task.slackChannelId,
93
+ thread_ts: task.slackThreadTs,
94
+ text:
95
+ task.slackReplySent || i > 0
96
+ ? `✅ ${agentName} completed${i > 0 ? ` (continued ${i + 1}/${blockBatches.length})` : ""}`
97
+ : body,
98
+ username: getAgentDisplayName(agent),
99
+ icon_emoji: getAgentEmoji(agent),
100
+ blocks: blockBatches[i],
101
+ });
102
+ }
96
103
  } else if (task.status === "failed") {
97
104
  const reason = task.failureReason || "Unknown error";
98
105
  const blocks = buildFailedBlocks({ agentName, taskId: task.id, reason });
@@ -199,6 +206,7 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
199
206
  const agentName = agent.name;
200
207
  let blocks: unknown[];
201
208
  let text: string;
209
+ let completionBlockBatches: unknown[][] | undefined;
202
210
 
203
211
  if (task.status === "completed") {
204
212
  const output = task.output || "Task completed.";
@@ -212,14 +220,16 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
212
220
  console.log(
213
221
  `[Slack] updateToFinal: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
214
222
  );
215
- blocks = buildCompletedBlocks({
223
+ const completionOpts = {
216
224
  agentName,
217
225
  taskId: task.id,
218
226
  body,
219
227
  duration,
220
228
  minimal: !!task.slackReplySent,
221
229
  trailer: task.slackReplySent ? attachmentsBlock : undefined,
222
- });
230
+ };
231
+ completionBlockBatches = buildCompletedBlockBatches(completionOpts);
232
+ blocks = completionBlockBatches[0] ?? buildCompletedBlocks(completionOpts);
223
233
  text = task.slackReplySent ? `✅ ${agentName} completed` : body;
224
234
  } else if (task.status === "cancelled") {
225
235
  blocks = buildCancelledBlocks({ agentName, taskId: task.id });
@@ -238,6 +248,19 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
238
248
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
239
249
  blocks: blocks as any,
240
250
  });
251
+
252
+ if (completionBlockBatches) {
253
+ for (let i = 1; i < completionBlockBatches.length; i++) {
254
+ await sendWithPersona(app.client, {
255
+ channel: task.slackChannelId,
256
+ thread_ts: task.slackThreadTs ?? messageTs,
257
+ text: `✅ ${agentName} completed (continued ${i + 1}/${completionBlockBatches.length})`,
258
+ username: getAgentDisplayName(agent),
259
+ icon_emoji: getAgentEmoji(agent),
260
+ blocks: completionBlockBatches[i],
261
+ });
262
+ }
263
+ }
241
264
  return true;
242
265
  } catch (error) {
243
266
  console.error(`[Slack] Failed to update task message to final state:`, error);
@@ -696,13 +696,6 @@ export function startTaskWatcher(intervalMs = 3000): void {
696
696
  }
697
697
  }
698
698
 
699
- // Skip tasks tracked in a tree — they're rendered by processTreeMessages()
700
- // But mark as notified to prevent re-processing if tree is cleaned up
701
- if (taskToTree.has(task.id)) {
702
- notifiedCompletions.set(task.id, now);
703
- continue;
704
- }
705
-
706
699
  const completionKey = `completion:${task.id}`;
707
700
 
708
701
  // Skip if already notified or currently sending or sent recently
@@ -710,6 +703,34 @@ export function startTaskWatcher(intervalMs = 3000): void {
710
703
  const lastSent = lastSendTime.get(completionKey);
711
704
  if (lastSent && now - lastSent < MIN_SEND_INTERVAL) continue;
712
705
 
706
+ // Tasks tracked in a tree are still rendered by processTreeMessages().
707
+ // Successful tasks without their own slack-reply also get a full
708
+ // threaded completion message so the tree's compact preview is not the
709
+ // only place their output appears.
710
+ if (taskToTree.has(task.id)) {
711
+ if (task.status !== "completed" || task.slackReplySent) {
712
+ notifiedCompletions.set(task.id, now);
713
+ continue;
714
+ }
715
+
716
+ pendingSends.add(completionKey);
717
+ notifiedCompletions.set(task.id, now);
718
+ lastSendTime.set(completionKey, now);
719
+ try {
720
+ await sendTaskResponse(task);
721
+ console.log(
722
+ `[Slack] Sent full tree-tracked completion for task ${task.id.slice(0, 8)}`,
723
+ );
724
+ } catch (error) {
725
+ notifiedCompletions.delete(task.id);
726
+ lastSendTime.delete(completionKey);
727
+ console.error(`[Slack] Failed to send tree-tracked completion:`, error);
728
+ } finally {
729
+ pendingSends.delete(completionKey);
730
+ }
731
+ continue;
732
+ }
733
+
713
734
  // Mark as pending and notified BEFORE sending
714
735
  pendingSends.add(completionKey);
715
736
  notifiedCompletions.set(task.id, now);
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Type } from "typebox";
3
3
  import { z } from "zod";
4
- import { completeStructured } from "../../utils/internal-ai/complete-structured.js";
4
+ import {
5
+ completeStructured,
6
+ defaultSpawnClaudeCli,
7
+ } from "../../utils/internal-ai/complete-structured.js";
5
8
  import type { ResolvedCredential } from "../../utils/internal-ai/credentials.js";
6
9
 
7
10
  const ResultZodSchema = z.object({
@@ -274,3 +277,33 @@ describe("completeStructured", () => {
274
277
  expect(match).toBeDefined();
275
278
  });
276
279
  });
280
+
281
+ describe("defaultSpawnClaudeCli", () => {
282
+ test("sets SKIP_SESSION_SUMMARY=1 in the child env (Stop-hook recursion guard)", async () => {
283
+ // Without this guard, the spawned `claude -p` summarizer session fires the
284
+ // same global Stop hook on exit, which spawns another summarizer claude,
285
+ // recursively — observed OOM-wedging 8GB E2B worker sandboxes.
286
+ const fakeBinary = `/tmp/fake-claude-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`;
287
+ await Bun.write(
288
+ fakeBinary,
289
+ '#!/usr/bin/env bash\ncat >/dev/null\nprintf \'{"result":"SKIP_SESSION_SUMMARY=%s"}\' "$SKIP_SESSION_SUMMARY"\n',
290
+ );
291
+ const savedBinary = process.env.CLAUDE_BINARY;
292
+ const savedSkip = process.env.SKIP_SESSION_SUMMARY;
293
+ const savedBridge = process.env.SWARM_USE_CLAUDE_BRIDGE;
294
+ process.env.CLAUDE_BINARY = `bash ${fakeBinary}`;
295
+ // Ensure a false-pass via inheritance is impossible.
296
+ delete process.env.SKIP_SESSION_SUMMARY;
297
+ delete process.env.SWARM_USE_CLAUDE_BRIDGE;
298
+ try {
299
+ const out = await defaultSpawnClaudeCli("prompt", "haiku");
300
+ expect(out).toBe("SKIP_SESSION_SUMMARY=1");
301
+ } finally {
302
+ if (savedBinary === undefined) delete process.env.CLAUDE_BINARY;
303
+ else process.env.CLAUDE_BINARY = savedBinary;
304
+ if (savedSkip !== undefined) process.env.SKIP_SESSION_SUMMARY = savedSkip;
305
+ if (savedBridge !== undefined) process.env.SWARM_USE_CLAUDE_BRIDGE = savedBridge;
306
+ await Bun.$`rm -f ${fakeBinary}`.quiet();
307
+ }
308
+ });
309
+ });