@desplega.ai/agent-swarm 1.69.0 → 1.70.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 (44) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +62 -1
  3. package/package.json +1 -1
  4. package/src/agentmail/handlers.ts +87 -6
  5. package/src/be/db.ts +34 -2
  6. package/src/be/migrations/042_task_context_key.sql +13 -0
  7. package/src/commands/runner.ts +1 -0
  8. package/src/github/handlers.ts +42 -10
  9. package/src/gitlab/handlers.ts +29 -5
  10. package/src/hooks/hook.ts +4 -2
  11. package/src/http/core.ts +36 -26
  12. package/src/http/mcp-oauth.ts +132 -60
  13. package/src/http/mcp-servers.ts +5 -1
  14. package/src/http/schedules.ts +4 -2
  15. package/src/http/tasks.ts +4 -2
  16. package/src/linear/sync.ts +22 -10
  17. package/src/providers/claude-adapter.ts +51 -29
  18. package/src/scheduler/scheduler.ts +9 -10
  19. package/src/server.ts +2 -0
  20. package/src/slack/actions.ts +10 -9
  21. package/src/slack/assistant.ts +8 -4
  22. package/src/slack/handlers.ts +8 -3
  23. package/src/slack/thread-buffer.ts +61 -72
  24. package/src/tasks/additive-buffer.ts +152 -0
  25. package/src/tasks/additive-ingress.ts +125 -0
  26. package/src/tasks/context-key.ts +245 -0
  27. package/src/tasks/sibling-awareness.ts +144 -0
  28. package/src/tasks/sibling-block.ts +164 -0
  29. package/src/tests/additive-buffer.test.ts +186 -0
  30. package/src/tests/additive-ingress.test.ts +111 -0
  31. package/src/tests/claude-adapter.test.ts +143 -1
  32. package/src/tests/context-key-db.test.ts +87 -0
  33. package/src/tests/context-key.test.ts +173 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  36. package/src/tests/sibling-awareness-db.test.ts +172 -0
  37. package/src/tests/sibling-block.test.ts +232 -0
  38. package/src/tests/tool-annotations.test.ts +1 -0
  39. package/src/tools/slack-post.ts +10 -3
  40. package/src/tools/slack-start-thread.ts +123 -0
  41. package/src/tools/tool-config.ts +2 -1
  42. package/src/tools/update-profile.ts +5 -2
  43. package/src/types.ts +5 -0
  44. package/src/workflows/executors/agent-task.ts +21 -14
package/src/hooks/hook.ts CHANGED
@@ -355,8 +355,10 @@ export async function handleHook(): Promise<void> {
355
355
  }
356
356
  };
357
357
 
358
- // Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption
359
- const IDENTITY_FILE_MIN_LENGTH = 100;
358
+ // Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
359
+ // Raised from 100 to 500 after Picateclas profile corruption recurrences where
360
+ // a 234-char test sentinel payload was syncing into the real agent's DB row.
361
+ const IDENTITY_FILE_MIN_LENGTH = 500;
360
362
 
361
363
  /**
362
364
  * Sync SOUL.md and IDENTITY.md content back to the server
package/src/http/core.ts CHANGED
@@ -16,7 +16,27 @@ import { startSlackApp, stopSlackApp } from "../slack";
16
16
  import type { AgentStatus } from "../types";
17
17
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
18
18
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
19
- import { agentWithCapacity, parseQueryParams } from "./utils";
19
+ import { routeRegistry } from "./route-def";
20
+ import { agentWithCapacity, getPathSegments, matchRoute, parseQueryParams } from "./utils";
21
+
22
+ /**
23
+ * Check whether a request targets a route declared (via the `route()` factory)
24
+ * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
25
+ * bearer check. Handler files must use the `route()` factory for this to take
26
+ * effect; unknown paths fail closed (auth required).
27
+ */
28
+ function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
29
+ for (const def of routeRegistry) {
30
+ if (def.auth?.apiKey === false) {
31
+ if (
32
+ matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
33
+ ) {
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ return false;
39
+ }
20
40
 
21
41
  /**
22
42
  * Load global swarm_config entries into process.env.
@@ -121,31 +141,21 @@ export async function handleCore(
121
141
  return true;
122
142
  }
123
143
 
124
- // API key authentication (if API_KEY is configured)
125
- // Skip auth for webhooks (they have their own signature verification)
126
- const isGitHubWebhook = req.url?.startsWith("/api/github/webhook");
127
- const isGitLabWebhook = req.url?.startsWith("/api/gitlab/webhook");
128
- const isAgentMailWebhook = req.url?.startsWith("/api/agentmail/webhook");
129
- const isTrackerAuth =
130
- req.url?.startsWith("/api/trackers/linear/authorize") ||
131
- req.url?.startsWith("/api/trackers/linear/callback") ||
132
- req.url?.startsWith("/api/trackers/linear/webhook");
133
- const isWorkflowWebhook = req.url?.startsWith("/api/webhooks/");
134
- if (
135
- apiKey &&
136
- !isGitHubWebhook &&
137
- !isGitLabWebhook &&
138
- !isAgentMailWebhook &&
139
- !isTrackerAuth &&
140
- !isWorkflowWebhook
141
- ) {
142
- const authHeader = req.headers.authorization;
143
- const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
144
-
145
- if (providedKey !== apiKey) {
146
- res.writeHead(401, { "Content-Type": "application/json" });
147
- res.end(JSON.stringify({ error: "Unauthorized" }));
148
- return true;
144
+ // API-key authentication (if API_KEY is configured). Routes that opt out via
145
+ // `route({ auth: { apiKey: false } })` webhooks, OAuth provider callbacks,
146
+ // etc. are skipped based on the central `routeRegistry`. Unknown paths
147
+ // fall through to the bearer check (fail-closed).
148
+ if (apiKey) {
149
+ const pathSegments = getPathSegments(req.url || "");
150
+ if (!isPublicRoute(req.method, pathSegments)) {
151
+ const authHeader = req.headers.authorization;
152
+ const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
153
+
154
+ if (providedKey !== apiKey) {
155
+ res.writeHead(401, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify({ error: "Unauthorized" }));
157
+ return true;
158
+ }
149
159
  }
150
160
  }
151
161
 
@@ -158,6 +158,27 @@ const authorizeRoute = route({
158
158
  },
159
159
  });
160
160
 
161
+ const authorizeUrlRoute = route({
162
+ method: "get",
163
+ path: "/api/mcp-oauth/{mcpServerId}/authorize-url",
164
+ pattern: ["api", "mcp-oauth", null, "authorize-url"],
165
+ summary:
166
+ "Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
167
+ tags: ["MCP OAuth"],
168
+ auth: { apiKey: true },
169
+ params: z.object({ mcpServerId: z.string() }),
170
+ query: z.object({
171
+ redirect: z.string().optional(),
172
+ userId: z.string().optional(),
173
+ scopes: z.string().optional(),
174
+ }),
175
+ responses: {
176
+ 200: { description: "{ providerUrl: string }" },
177
+ 400: { description: "MCP has no URL / does not require OAuth" },
178
+ 404: { description: "MCP server not found" },
179
+ },
180
+ });
181
+
161
182
  const callbackRoute = route({
162
183
  method: "get",
163
184
  path: "/api/mcp-oauth/callback",
@@ -236,6 +257,86 @@ const manualClientRoute = route({
236
257
  },
237
258
  });
238
259
 
260
+ // ─── Shared authorize flow ───────────────────────────────────────────────────
261
+
262
+ interface AuthorizeFlowQuery {
263
+ redirect?: string;
264
+ userId?: string;
265
+ scopes?: string;
266
+ }
267
+
268
+ /**
269
+ * Discover metadata, DCR-register (or fail), build the authorize URL, and
270
+ * persist the pending session. Returns the provider `providerUrl` the caller
271
+ * should redirect to / respond with. On failure, writes a JSON error response
272
+ * and returns null.
273
+ */
274
+ async function prepareAuthorizeFlow(
275
+ res: ServerResponse,
276
+ mcpServerId: string,
277
+ server: NonNullable<ReturnType<typeof getMcpServerById>>,
278
+ q: AuthorizeFlowQuery,
279
+ ): Promise<string | null> {
280
+ const discovery = await discoverForMcp(server.url!);
281
+ if (!discovery) {
282
+ jsonError(res, "MCP server does not require OAuth", 400);
283
+ return null;
284
+ }
285
+
286
+ let clientId: string | null = null;
287
+ let clientSecret: string | null = null;
288
+ if (discovery.dcrSupported && discovery.registrationEndpoint) {
289
+ const dcr = await registerClient(discovery.registrationEndpoint, {
290
+ client_name: `agent-swarm (${server.name})`,
291
+ redirect_uris: [callbackRedirectUri()],
292
+ grant_types: ["authorization_code", "refresh_token"],
293
+ response_types: ["code"],
294
+ token_endpoint_auth_method: "client_secret_basic",
295
+ application_type: "web",
296
+ scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
297
+ });
298
+ clientId = dcr.client_id;
299
+ clientSecret = dcr.client_secret ?? null;
300
+ } else {
301
+ jsonError(
302
+ res,
303
+ "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
304
+ 400,
305
+ );
306
+ return null;
307
+ }
308
+
309
+ const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
310
+
311
+ const built = await buildAuthorizeUrl({
312
+ authorizeUrl: discovery.authorizeUrl,
313
+ tokenUrl: discovery.tokenUrl,
314
+ clientId: clientId!,
315
+ redirectUri: callbackRedirectUri(),
316
+ scopes,
317
+ resource: discovery.resourceUrl,
318
+ });
319
+
320
+ insertMcpOAuthPending({
321
+ state: built.state,
322
+ mcpServerId,
323
+ userId: q.userId ?? null,
324
+ codeVerifier: built.codeVerifier,
325
+ resourceUrl: discovery.resourceUrl,
326
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
327
+ authorizeUrl: discovery.authorizeUrl,
328
+ tokenUrl: discovery.tokenUrl,
329
+ revocationUrl: discovery.revocationUrl,
330
+ scopes: scopes.join(" "),
331
+ dcrClientId: clientId!,
332
+ dcrClientSecret: clientSecret,
333
+ redirectUri: callbackRedirectUri(),
334
+ finalRedirect: q.redirect ?? null,
335
+ });
336
+
337
+ return built.url;
338
+ }
339
+
239
340
  // ─── Handler ─────────────────────────────────────────────────────────────────
240
341
 
241
342
  export async function handleMcpOAuth(
@@ -398,69 +499,40 @@ export async function handleMcpOAuth(
398
499
  if (!server) return true;
399
500
 
400
501
  try {
401
- const discovery = await discoverForMcp(server.url!);
402
- if (!discovery) {
403
- jsonError(res, "MCP server does not require OAuth", 400);
404
- return true;
405
- }
406
-
407
- // Dynamic Client Registration if supported, otherwise expect the user to
408
- // have already called /manual-client.
409
- let clientId: string | null = null;
410
- let clientSecret: string | null = null;
411
- if (discovery.dcrSupported && discovery.registrationEndpoint) {
412
- const dcr = await registerClient(discovery.registrationEndpoint, {
413
- client_name: `agent-swarm (${server.name})`,
414
- redirect_uris: [callbackRedirectUri()],
415
- grant_types: ["authorization_code", "refresh_token"],
416
- response_types: ["code"],
417
- token_endpoint_auth_method: "client_secret_basic",
418
- application_type: "web",
419
- scope: (parsed.query.scopes ?? discovery.scopes.join(" ")) || undefined,
420
- });
421
- clientId = dcr.client_id;
422
- clientSecret = dcr.client_secret ?? null;
423
- } else {
424
- jsonError(
425
- res,
426
- "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
427
- 400,
428
- );
429
- return true;
430
- }
502
+ const providerUrl = await prepareAuthorizeFlow(
503
+ res,
504
+ parsed.params.mcpServerId,
505
+ server,
506
+ parsed.query,
507
+ );
508
+ if (!providerUrl) return true;
509
+ res.writeHead(302, { Location: providerUrl });
510
+ res.end();
511
+ } catch (err) {
512
+ const message = err instanceof Error ? err.message : String(err);
513
+ jsonError(res, `Authorize failed: ${message}`, 502);
514
+ }
515
+ return true;
516
+ }
431
517
 
432
- const scopes = parsed.query.scopes
433
- ? parsed.query.scopes.split(" ").filter(Boolean)
434
- : discovery.scopes;
435
-
436
- const built = await buildAuthorizeUrl({
437
- authorizeUrl: discovery.authorizeUrl,
438
- tokenUrl: discovery.tokenUrl,
439
- clientId: clientId!,
440
- redirectUri: callbackRedirectUri(),
441
- scopes,
442
- resource: discovery.resourceUrl,
443
- });
518
+ // GET /api/mcp-oauth/:id/authorize-url — JSON variant of /authorize so the
519
+ // dashboard can fetch the provider URL with Bearer auth and then navigate.
520
+ if (authorizeUrlRoute.match(req.method, pathSegments)) {
521
+ const parsed = await authorizeUrlRoute.parse(req, res, pathSegments, queryParams);
522
+ if (!parsed) return true;
444
523
 
445
- insertMcpOAuthPending({
446
- state: built.state,
447
- mcpServerId: parsed.params.mcpServerId,
448
- userId: parsed.query.userId ?? null,
449
- codeVerifier: built.codeVerifier,
450
- resourceUrl: discovery.resourceUrl,
451
- authorizationServerIssuer: discovery.authorizationServerIssuer,
452
- authorizeUrl: discovery.authorizeUrl,
453
- tokenUrl: discovery.tokenUrl,
454
- revocationUrl: discovery.revocationUrl,
455
- scopes: scopes.join(" "),
456
- dcrClientId: clientId!,
457
- dcrClientSecret: clientSecret,
458
- redirectUri: callbackRedirectUri(),
459
- finalRedirect: parsed.query.redirect ?? null,
460
- });
524
+ const server = getMcpOrError(res, parsed.params.mcpServerId);
525
+ if (!server) return true;
461
526
 
462
- res.writeHead(302, { Location: built.url });
463
- res.end();
527
+ try {
528
+ const providerUrl = await prepareAuthorizeFlow(
529
+ res,
530
+ parsed.params.mcpServerId,
531
+ server,
532
+ parsed.query,
533
+ );
534
+ if (!providerUrl) return true;
535
+ json(res, { providerUrl });
464
536
  } catch (err) {
465
537
  const message = err instanceof Error ? err.message : String(err);
466
538
  jsonError(res, `Authorize failed: ${message}`, 502);
@@ -243,7 +243,11 @@ export async function handleMcpServers(
243
243
  try {
244
244
  const token = await ensureMcpToken(server.id);
245
245
  if (token && token.status === "connected") {
246
- const prefix = token.tokenType || "Bearer";
246
+ // Normalize the bearer scheme to capital "Bearer": some resource
247
+ // servers reject the lowercase "bearer" RFC 6749 returns (issue #368).
248
+ // Non-bearer schemes (e.g. "MAC") are preserved verbatim.
249
+ const rawType = token.tokenType || "Bearer";
250
+ const prefix = rawType.toLowerCase() === "bearer" ? "Bearer" : rawType;
247
251
  resolvedHeaders.Authorization = `${prefix} ${token.accessToken}`;
248
252
  } else if (!token) {
249
253
  authError = "No OAuth token for this MCP server";
@@ -3,7 +3,6 @@ import { CronExpressionParser } from "cron-parser";
3
3
  import { z } from "zod";
4
4
  import {
5
5
  createScheduledTask,
6
- createTaskExtended,
7
6
  deleteScheduledTask,
8
7
  getAgentById,
9
8
  getDb,
@@ -12,6 +11,8 @@ import {
12
11
  updateScheduledTask,
13
12
  } from "../be/db";
14
13
  import { calculateNextRun } from "../scheduler/scheduler";
14
+ import { scheduleContextKey } from "../tasks/context-key";
15
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
15
16
  import { getExecutorRegistry } from "../workflows";
16
17
  import { handleScheduleTrigger } from "../workflows/triggers";
17
18
  import { route } from "./route-def";
@@ -285,7 +286,7 @@ export async function handleSchedules(
285
286
  const now = new Date().toISOString();
286
287
 
287
288
  const task = getDb().transaction(() => {
288
- const createdTask = createTaskExtended(schedule.taskTemplate, {
289
+ const createdTask = createTaskWithSiblingAwareness(schedule.taskTemplate, {
289
290
  creatorAgentId: schedule.createdByAgentId,
290
291
  taskType: schedule.taskType,
291
292
  tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
@@ -294,6 +295,7 @@ export async function handleSchedules(
294
295
  model: schedule.model,
295
296
  scheduleId: schedule.id,
296
297
  source: "schedule",
298
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
297
299
  });
298
300
 
299
301
  if (schedule.scheduleType === "one_time") {
package/src/http/tasks.ts CHANGED
@@ -4,7 +4,6 @@ import { z } from "zod";
4
4
  import {
5
5
  cancelTask,
6
6
  completeTask,
7
- createTaskExtended,
8
7
  failTask,
9
8
  getAllTasks,
10
9
  getDb,
@@ -19,6 +18,7 @@ import {
19
18
  updateTaskProgress,
20
19
  updateTaskVcs,
21
20
  } from "../be/db";
21
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
22
22
  import { telemetry } from "../telemetry";
23
23
  import { route } from "./route-def";
24
24
  import { json, jsonError } from "./utils";
@@ -63,6 +63,7 @@ const createTask = route({
63
63
  parentTaskId: z.string().optional(),
64
64
  source: z.string().optional(),
65
65
  outputSchema: z.record(z.string(), z.unknown()).optional(),
66
+ contextKey: z.string().optional(),
66
67
  }),
67
68
  responses: {
68
69
  201: { description: "Task created" },
@@ -240,7 +241,7 @@ export async function handleTasks(
240
241
  if (!parsed) return true;
241
242
 
242
243
  try {
243
- const task = createTaskExtended(parsed.body.task, {
244
+ const task = createTaskWithSiblingAwareness(parsed.body.task, {
244
245
  agentId: parsed.body.agentId || undefined,
245
246
  creatorAgentId: myAgentId || undefined,
246
247
  taskType: parsed.body.taskType || undefined,
@@ -252,6 +253,7 @@ export async function handleTasks(
252
253
  parentTaskId: parsed.body.parentTaskId || undefined,
253
254
  source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
254
255
  outputSchema: parsed.body.outputSchema || undefined,
256
+ contextKey: parsed.body.contextKey || undefined,
255
257
  });
256
258
 
257
259
  ensure({
@@ -1,4 +1,4 @@
1
- import { cancelTask, createTaskExtended, getAllAgents, getTaskById, resolveUser } from "../be/db";
1
+ import { cancelTask, getAllAgents, getTaskById, resolveUser } from "../be/db";
2
2
  import { getOAuthTokens } from "../be/db-queries/oauth";
3
3
  import {
4
4
  createTrackerSync,
@@ -8,6 +8,8 @@ import {
8
8
  } from "../be/db-queries/tracker";
9
9
  import { ensureToken } from "../oauth/ensure-token";
10
10
  import { resolveTemplate } from "../prompts/resolver";
11
+ import { linearContextKey } from "../tasks/context-key";
12
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
11
13
  // Side-effect import: registers all Linear event templates in the in-memory registry
12
14
  import "./templates";
13
15
 
@@ -287,18 +289,26 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
287
289
  if (existing) {
288
290
  const existingTask = getTaskById(existing.swarmId);
289
291
 
290
- // If the task is still active, acknowledge the new session but don't create a duplicate
292
+ // If the task is still active, post a user-visible response on the new
293
+ // session explaining that a sibling is already in flight and the new
294
+ // session can be closed. Do NOT create a duplicate swarm task. If the user
295
+ // wants to force a fresh run, they can re-assign the issue after the
296
+ // current task finishes.
291
297
  if (existingTask && !["completed", "failed", "cancelled"].includes(existingTask.status)) {
292
298
  console.log(
293
- `[Linear Sync] Issue ${issueIdentifier} already tracked as active task ${existing.swarmId}, skipping`,
299
+ `[Linear Sync] Issue ${issueIdentifier} already tracked as active task ${existing.swarmId} (status: ${existingTask.status}), skipping duplicate`,
294
300
  );
295
301
  if (sessionId) {
296
302
  taskSessionMap.set(existingTask.id, sessionId);
297
- acknowledgeAgentSession(
298
- sessionId,
299
- `This issue is already being worked on (task ${existing.swarmId}).`,
300
- ).catch((err) => {
301
- console.error("[Linear Sync] Failed to acknowledge duplicate AgentSession:", err);
303
+ const refuseMsg = [
304
+ `This issue is already being worked on — task \`${existing.swarmId}\` is currently \`${existingTask.status}\`.`,
305
+ "",
306
+ "To avoid duplicating work, I'm not starting a new session for this re-assignment. Progress on the active task will continue to be posted here.",
307
+ "",
308
+ "If you want to force a fresh run, wait for the current task to finish (or cancel it) and re-assign the issue.",
309
+ ].join("\n");
310
+ postAgentSessionResponse(sessionId, refuseMsg).catch((err) => {
311
+ console.error("[Linear Sync] Failed to post hard-refuse response:", err);
302
312
  });
303
313
  }
304
314
  return;
@@ -327,11 +337,12 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
327
337
  return;
328
338
  }
329
339
 
330
- const task = createTaskExtended(templateResult.text, {
340
+ const task = createTaskWithSiblingAwareness(templateResult.text, {
331
341
  agentId: lead?.id ?? "",
332
342
  source: "linear",
333
343
  taskType: "linear-issue",
334
344
  requestedByUserId,
345
+ contextKey: linearContextKey({ issueIdentifier }),
335
346
  });
336
347
 
337
348
  // Delete old tracker_sync before creating new one (UNIQUE constraint)
@@ -570,11 +581,12 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
570
581
  return;
571
582
  }
572
583
 
573
- const task = createTaskExtended(followupResult.text, {
584
+ const task = createTaskWithSiblingAwareness(followupResult.text, {
574
585
  agentId: lead?.id ?? "",
575
586
  source: "linear",
576
587
  taskType: "linear-issue",
577
588
  requestedByUserId: promptedRequestedByUserId,
589
+ contextKey: linearContextKey({ issueIdentifier }),
578
590
  });
579
591
 
580
592
  // Repoint the existing tracker_sync to the new follow-up task (can't create a
@@ -106,6 +106,52 @@ async function fetchInstalledMcpServers(
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * Merge a base MCP config (typically read from `.mcp.json`) with freshly-resolved
111
+ * installed servers from the API, and inject the per-task `X-Source-Task-Id` header
112
+ * into the `agent-swarm` entry.
113
+ *
114
+ * Precedence: installed servers from the API WIN over entries already in `.mcp.json`.
115
+ * This guards against stale credentials from a `.mcp.json` that was written once at
116
+ * container startup and never refreshed (see issue #369). The per-session fetch
117
+ * carries current OAuth tokens / rotated secrets / up-to-date installs.
118
+ *
119
+ * Exported for unit testing.
120
+ */
121
+ export function mergeMcpConfig(
122
+ baseConfig: { mcpServers?: Record<string, unknown> } | null,
123
+ installedServers: Record<string, Record<string, unknown>> | null,
124
+ taskId: string,
125
+ ): { mcpServers: Record<string, unknown> } {
126
+ const config: { mcpServers: Record<string, unknown> } = {
127
+ mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
128
+ };
129
+
130
+ // Installed servers from the API always win — fresh credentials replace stale ones.
131
+ if (installedServers) {
132
+ for (const [name, serverConfig] of Object.entries(installedServers)) {
133
+ config.mcpServers[name] = serverConfig;
134
+ }
135
+ }
136
+
137
+ // Find the agent-swarm server entry (could be named "agent-swarm" or similar)
138
+ const serverKey = Object.keys(config.mcpServers).find(
139
+ (k) =>
140
+ k === "agent-swarm" ||
141
+ ((config.mcpServers[k] as Record<string, unknown>)?.headers &&
142
+ ((config.mcpServers[k] as Record<string, Record<string, unknown>>).headers?.[
143
+ "X-Agent-ID"
144
+ ] as unknown)),
145
+ );
146
+ if (serverKey) {
147
+ const server = config.mcpServers[serverKey] as Record<string, unknown>;
148
+ if (!server.headers) server.headers = {};
149
+ (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
150
+ }
151
+
152
+ return config;
153
+ }
154
+
109
155
  /**
110
156
  * Create a per-session MCP config file with X-Source-Task-Id header injected
111
157
  * and installed MCP servers merged in.
@@ -138,39 +184,14 @@ async function createSessionMcpConfig(
138
184
  if (!mcpJsonPath && !installedServers) return null;
139
185
 
140
186
  try {
141
- let config: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
187
+ let baseConfig: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
142
188
  if (mcpJsonPath) {
143
189
  const file = Bun.file(mcpJsonPath);
144
- config = await file.json();
145
- }
146
- const servers = config?.mcpServers;
147
- if (!servers && !installedServers) return null;
148
-
149
- if (!config.mcpServers) config.mcpServers = {};
150
-
151
- // Find the agent-swarm server entry (could be named "agent-swarm" or similar)
152
- const serverKey = Object.keys(config.mcpServers).find(
153
- (k) =>
154
- k === "agent-swarm" ||
155
- ((config.mcpServers![k] as Record<string, unknown>)?.headers &&
156
- ((config.mcpServers![k] as Record<string, Record<string, unknown>>).headers?.[
157
- "X-Agent-ID"
158
- ] as unknown)),
159
- );
160
- if (serverKey) {
161
- const server = config.mcpServers[serverKey] as Record<string, unknown>;
162
- if (!server.headers) server.headers = {};
163
- (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
190
+ baseConfig = await file.json();
164
191
  }
192
+ if (!baseConfig?.mcpServers && !installedServers) return null;
165
193
 
166
- // Merge installed MCP servers (don't overwrite existing entries)
167
- if (installedServers) {
168
- for (const [name, serverConfig] of Object.entries(installedServers)) {
169
- if (!config.mcpServers[name]) {
170
- config.mcpServers[name] = serverConfig;
171
- }
172
- }
173
- }
194
+ const config = mergeMcpConfig(baseConfig, installedServers ?? null, taskId);
174
195
 
175
196
  // Write per-session config to /tmp — no race, each session has its own file
176
197
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
@@ -212,6 +233,7 @@ class ClaudeSession implements ProviderSession {
212
233
  this.proc = Bun.spawn(cmd, {
213
234
  cwd: this.config.cwd,
214
235
  env: {
236
+ ENABLE_PROMPT_CACHING_1H: "1",
215
237
  ...(config.env || process.env),
216
238
  TASK_FILE: taskFilePath,
217
239
  } as Record<string, string>,
@@ -1,12 +1,8 @@
1
1
  import { ensure } from "@desplega.ai/business-use";
2
2
  import { CronExpressionParser } from "cron-parser";
3
- import {
4
- createTaskExtended,
5
- getDb,
6
- getDueScheduledTasks,
7
- getScheduledTaskById,
8
- updateScheduledTask,
9
- } from "@/be/db";
3
+ import { getDb, getDueScheduledTasks, getScheduledTaskById, updateScheduledTask } from "@/be/db";
4
+ import { scheduleContextKey } from "@/tasks/context-key";
5
+ import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
10
6
  import type { ScheduledTask } from "@/types";
11
7
  import type { ExecutorRegistry } from "@/workflows/executors/registry";
12
8
  import { handleScheduleTrigger } from "@/workflows/triggers";
@@ -49,7 +45,7 @@ async function recoverMissedSchedules(): Promise<void> {
49
45
 
50
46
  if (!triggeredWorkflows) {
51
47
  const tx = getDb().transaction(() => {
52
- createTaskExtended(schedule.taskTemplate, {
48
+ createTaskWithSiblingAwareness(schedule.taskTemplate, {
53
49
  creatorAgentId: schedule.createdByAgentId,
54
50
  taskType: schedule.taskType,
55
51
  tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "recovered"],
@@ -58,6 +54,7 @@ async function recoverMissedSchedules(): Promise<void> {
58
54
  model: schedule.model,
59
55
  scheduleId: schedule.id,
60
56
  source: "schedule",
57
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
61
58
  });
62
59
  });
63
60
  tx();
@@ -153,7 +150,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
153
150
  if (!triggeredWorkflows) {
154
151
  // No workflows linked — create standalone task (existing behavior)
155
152
  getDb().transaction(() => {
156
- createTaskExtended(schedule.taskTemplate, {
153
+ createTaskWithSiblingAwareness(schedule.taskTemplate, {
157
154
  creatorAgentId: schedule.createdByAgentId,
158
155
  taskType: schedule.taskType,
159
156
  tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`],
@@ -162,6 +159,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
162
159
  model: schedule.model,
163
160
  scheduleId: schedule.id,
164
161
  source: "schedule",
162
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
165
163
  });
166
164
  })();
167
165
  }
@@ -343,7 +341,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
343
341
  if (!triggeredWorkflows) {
344
342
  // No workflows linked — create standalone task (existing behavior)
345
343
  getDb().transaction(() => {
346
- createTaskExtended(schedule.taskTemplate, {
344
+ createTaskWithSiblingAwareness(schedule.taskTemplate, {
347
345
  creatorAgentId: schedule.createdByAgentId,
348
346
  taskType: schedule.taskType,
349
347
  tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
@@ -352,6 +350,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
352
350
  model: schedule.model,
353
351
  scheduleId: schedule.id,
354
352
  source: "schedule",
353
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
355
354
  });
356
355
  })();
357
356
  }
package/src/server.ts CHANGED
@@ -77,6 +77,7 @@ import { registerSlackListChannelsTool } from "./tools/slack-list-channels";
77
77
  import { registerSlackPostTool } from "./tools/slack-post";
78
78
  import { registerSlackReadTool } from "./tools/slack-read";
79
79
  import { registerSlackReplyTool } from "./tools/slack-reply";
80
+ import { registerSlackStartThreadTool } from "./tools/slack-start-thread";
80
81
  import { registerSlackUploadFileTool } from "./tools/slack-upload-file";
81
82
  import { registerStoreProgressTool } from "./tools/store-progress";
82
83
  // Swarm config tools
@@ -189,6 +190,7 @@ export function createServer() {
189
190
  registerSlackReplyTool(server);
190
191
  registerSlackReadTool(server);
191
192
  registerSlackPostTool(server);
193
+ registerSlackStartThreadTool(server);
192
194
  registerSlackListChannelsTool(server);
193
195
  registerSlackUploadFileTool(server);
194
196
  registerSlackDownloadFileTool(server);