@desplega.ai/agent-swarm 1.87.0 → 1.89.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 (102) hide show
  1. package/README.md +5 -1
  2. package/openapi.json +53 -1
  3. package/package.json +6 -5
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +374 -9
  6. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  7. package/src/be/migrations/081_metrics.sql +39 -0
  8. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  9. package/src/be/modelsdev-cache.json +3825 -2417
  10. package/src/be/seed/registry.ts +3 -2
  11. package/src/be/seed-skills/index.ts +179 -0
  12. package/src/cli.tsx +51 -4
  13. package/src/commands/e2b-stack-wizard.tsx +394 -0
  14. package/src/commands/e2b.ts +1352 -53
  15. package/src/commands/onboard/dashboard-url.ts +29 -0
  16. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  17. package/src/commands/onboard.tsx +3 -1
  18. package/src/commands/runner.ts +154 -22
  19. package/src/commands/x.ts +118 -0
  20. package/src/e2b/dispatch.ts +234 -18
  21. package/src/github/handlers.ts +40 -1
  22. package/src/heartbeat/heartbeat.ts +26 -5
  23. package/src/http/active-sessions.ts +32 -1
  24. package/src/http/auth.ts +36 -0
  25. package/src/http/core.ts +20 -16
  26. package/src/http/db-query.ts +20 -0
  27. package/src/http/index.ts +2 -0
  28. package/src/http/memory.ts +13 -1
  29. package/src/http/metrics.ts +447 -0
  30. package/src/http/operator-actor.ts +9 -0
  31. package/src/http/poll.ts +11 -1
  32. package/src/http/skills.ts +53 -0
  33. package/src/http/tasks.ts +4 -1
  34. package/src/http/webhooks.ts +75 -0
  35. package/src/http/workflows.ts +5 -1
  36. package/src/integrations/kapso/client.ts +82 -0
  37. package/src/memory/automatic-task-gate.ts +47 -0
  38. package/src/metrics/version.ts +26 -0
  39. package/src/prompts/base-prompt.ts +24 -1
  40. package/src/prompts/session-templates.ts +74 -0
  41. package/src/providers/claude-adapter.ts +19 -0
  42. package/src/providers/codex-adapter.ts +22 -0
  43. package/src/providers/ctx-mode-env.ts +10 -0
  44. package/src/providers/opencode-adapter.ts +72 -7
  45. package/src/server.ts +10 -1
  46. package/src/slack/blocks.ts +12 -4
  47. package/src/slack/watcher.ts +3 -3
  48. package/src/telemetry.ts +14 -1
  49. package/src/templates.d.ts +4 -0
  50. package/src/tests/base-prompt.test.ts +76 -0
  51. package/src/tests/budget-claim-gate.test.ts +26 -0
  52. package/src/tests/claude-adapter.test.ts +86 -1
  53. package/src/tests/codex-adapter.test.ts +89 -0
  54. package/src/tests/core-auth.test.ts +8 -1
  55. package/src/tests/e2b-dispatch.test.ts +603 -11
  56. package/src/tests/events-http.test.ts +6 -2
  57. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  58. package/src/tests/heartbeat.test.ts +84 -3
  59. package/src/tests/http-api-integration.test.ts +116 -1
  60. package/src/tests/kapso-client.test.ts +74 -1
  61. package/src/tests/kapso-inbound.test.ts +60 -2
  62. package/src/tests/metrics-http.test.ts +247 -0
  63. package/src/tests/opencode-adapter.test.ts +185 -30
  64. package/src/tests/prompt-template-session.test.ts +4 -2
  65. package/src/tests/runner-repo-autostash.test.ts +117 -0
  66. package/src/tests/runner-requester-profile.test.ts +25 -0
  67. package/src/tests/runner-skills-refresh.test.ts +1 -1
  68. package/src/tests/self-improvement.test.ts +89 -0
  69. package/src/tests/skill-update-scope.test.ts +88 -1
  70. package/src/tests/slack-blocks.test.ts +15 -0
  71. package/src/tests/swarm-x-tool.test.ts +90 -0
  72. package/src/tests/system-default-skills.test.ts +122 -0
  73. package/src/tests/telemetry-init.test.ts +86 -0
  74. package/src/tests/ui-logs-parser.test.ts +271 -0
  75. package/src/tests/user-token-rest-auth.test.ts +129 -0
  76. package/src/tests/workflow-async-v2.test.ts +23 -0
  77. package/src/tests/x-composio.test.ts +122 -0
  78. package/src/tools/create-metric.ts +191 -0
  79. package/src/tools/skills/skill-delete.ts +14 -0
  80. package/src/tools/skills/skill-update.ts +14 -0
  81. package/src/tools/store-progress.ts +19 -5
  82. package/src/tools/swarm-x.ts +116 -0
  83. package/src/tools/tool-config.ts +6 -0
  84. package/src/types.ts +121 -0
  85. package/src/utils/request-auth-context.ts +28 -0
  86. package/src/utils/skills-refresh.ts +2 -2
  87. package/src/workflows/engine.ts +24 -2
  88. package/src/workflows/executors/agent-task.ts +2 -0
  89. package/src/x/composio.ts +295 -0
  90. package/templates/skills/artifacts/config.json +1 -0
  91. package/templates/skills/attio-interaction/SKILL.md +279 -0
  92. package/templates/skills/attio-interaction/config.json +14 -0
  93. package/templates/skills/attio-interaction/content.md +272 -0
  94. package/templates/skills/kv-storage/config.json +1 -0
  95. package/templates/skills/pages/config.json +1 -0
  96. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  97. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  98. package/templates/skills/swarm-scripts/config.json +14 -0
  99. package/templates/skills/swarm-scripts/content.md +86 -0
  100. package/templates/skills/workflow-iterate/config.json +1 -0
  101. package/templates/skills/workflow-structured-output/config.json +1 -0
  102. package/tsconfig.json +2 -1
@@ -20,6 +20,7 @@ import {
20
20
  import { validateOpencodeCredentials } from "../utils/credentials";
21
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
22
22
  import { scrubSecrets } from "../utils/secret-scrubber";
23
+ import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
23
24
  import type {
24
25
  CostData,
25
26
  CredCheckOptions,
@@ -176,6 +177,32 @@ function resolvePluginPath(): string {
176
177
  return join(import.meta.dir, "../../plugin/opencode-plugins/agent-swarm.ts");
177
178
  }
178
179
 
180
+ // context-mode is installed globally via `npm install -g` (Dockerfile.worker),
181
+ // which places it under the npm global modules dir. opencode resolves bare
182
+ // plugin names with `import(await Bun.resolve(name, ...))`, which does NOT walk
183
+ // the npm global dir — a bare "context-mode" entry only resolves if Bun
184
+ // auto-installs from the registry at runtime, which fails on network-sandboxed
185
+ // workers. So we hand opencode the ABSOLUTE path to the package's built
186
+ // opencode-plugin entry, which imports cleanly with no network.
187
+ const CONTEXT_MODE_GLOBAL_ROOTS = ["/usr/lib/node_modules", "/usr/local/lib/node_modules"];
188
+ const CONTEXT_MODE_PLUGIN_SUBPATH = "context-mode/build/adapters/opencode/plugin.js";
189
+
190
+ /**
191
+ * Resolve the absolute path to context-mode's opencode plugin entry, or `null`
192
+ * if it can't be found on disk. `CONTEXT_MODE_OPENCODE_PLUGIN_PATH` overrides
193
+ * the lookup (and must itself exist). Returning `null` lets the caller skip the
194
+ * plugin gracefully instead of handing opencode an unresolvable entry.
195
+ */
196
+ export function resolveContextModePluginPath(): string | null {
197
+ const override = process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
198
+ if (override) return existsSync(override) ? override : null;
199
+ for (const root of CONTEXT_MODE_GLOBAL_ROOTS) {
200
+ const candidate = join(root, CONTEXT_MODE_PLUGIN_SUBPATH);
201
+ if (existsSync(candidate)) return candidate;
202
+ }
203
+ return null;
204
+ }
205
+
179
206
  export class OpencodeSession implements ProviderSession {
180
207
  private _sessionId: string;
181
208
  private listeners: Array<(event: ProviderEvent) => void> = [];
@@ -192,6 +219,7 @@ export class OpencodeSession implements ProviderSession {
192
219
  private completionPromise: Promise<ProviderResult>;
193
220
  private server: { url: string; close(): void };
194
221
  private aborted = false;
222
+ private completed = false;
195
223
 
196
224
  // Running cost accumulators
197
225
  private totalCostUsd = 0;
@@ -246,6 +274,10 @@ export class OpencodeSession implements ProviderSession {
246
274
  return this._sessionId;
247
275
  }
248
276
 
277
+ get isFinished(): boolean {
278
+ return this.completed;
279
+ }
280
+
249
281
  /** Emit the synthetic session_init event. Called by the adapter immediately
250
282
  * after construction; buffers if no listener is attached yet. */
251
283
  emitSessionInit(provider: "opencode"): void {
@@ -331,7 +363,7 @@ export class OpencodeSession implements ProviderSession {
331
363
 
332
364
  /** Process a single opencode SSE event */
333
365
  handleOpencodeEvent(ev: OpencodeEvent): void {
334
- if (this.aborted) return;
366
+ if (this.aborted || this.completed) return;
335
367
 
336
368
  // Always emit the raw event as a scrubbed raw_log
337
369
  const rawContent = scrubSecrets(JSON.stringify(ev));
@@ -444,7 +476,7 @@ export class OpencodeSession implements ProviderSession {
444
476
  for (const l of this.listeners) l(resultEvent);
445
477
  const raw = scrubSecrets(JSON.stringify(resultEvent));
446
478
  this.emitDirect({ type: "raw_log", content: raw });
447
- this.completionResolve({
479
+ void this.finish({
448
480
  exitCode: 0,
449
481
  sessionId: this._sessionId,
450
482
  cost,
@@ -488,7 +520,7 @@ export class OpencodeSession implements ProviderSession {
488
520
  const raw = scrubSecrets(JSON.stringify(errorEvent));
489
521
  this.emitDirect({ type: "raw_log", content: raw });
490
522
  const cost = this.buildCostData(true);
491
- this.completionResolve({
523
+ void this.finish({
492
524
  exitCode: 1,
493
525
  sessionId: this._sessionId,
494
526
  cost,
@@ -519,12 +551,22 @@ export class OpencodeSession implements ProviderSession {
519
551
  return this.completionPromise;
520
552
  }
521
553
 
554
+ private async finish(result: ProviderResult): Promise<void> {
555
+ if (this.completed) return;
556
+ this.completed = true;
557
+ try {
558
+ this.server.close();
559
+ } catch {
560
+ // best-effort
561
+ }
562
+ await this.cleanupFiles();
563
+ this.completionResolve(result);
564
+ }
565
+
522
566
  async abort(): Promise<void> {
523
567
  if (this.aborted) return;
524
568
  this.aborted = true;
525
- this.server.close();
526
- await this.cleanupFiles();
527
- this.completionResolve({
569
+ await this.finish({
528
570
  exitCode: 1,
529
571
  sessionId: this._sessionId,
530
572
  isError: true,
@@ -588,6 +630,27 @@ export class OpencodeAdapter implements ProviderAdapter {
588
630
  // an accident, not a contract.
589
631
  const pluginPath = resolvePluginPath();
590
632
 
633
+ // context-mode ships as an in-process opencode plugin (NOT an MCP server).
634
+ // Its built plugin entry registers both the native ctx_* tools and the 5
635
+ // hook surrogates. It must NOT also appear in the `mcp` block — dual
636
+ // registration yields zero tools. We push the ABSOLUTE path to the globally
637
+ // installed package's opencode plugin entry, not the bare name (see
638
+ // resolveContextModePluginPath for why a bare name fails to resolve offline).
639
+ // Gated by CONTEXT_MODE_DISABLED so builds/deploys without it opt out.
640
+ const plugins = [pluginPath];
641
+ if (process.env.CONTEXT_MODE_DISABLED !== "true") {
642
+ const contextModePluginPath = resolveContextModePluginPath();
643
+ if (contextModePluginPath) {
644
+ plugins.push(contextModePluginPath);
645
+ } else {
646
+ console.warn(
647
+ "[opencode] context-mode is enabled but its opencode plugin entry was not found on disk; " +
648
+ "skipping it for this session. Set CONTEXT_MODE_OPENCODE_PLUGIN_PATH to override, or " +
649
+ "CONTEXT_MODE_DISABLED=true to silence.",
650
+ );
651
+ }
652
+ }
653
+
591
654
  // Build per-task opencode config (plugin field carries the swarm plugin)
592
655
  const opencodeConfig: Config & { plugin?: string[] } = {
593
656
  $schema: "https://opencode.ai/config.json",
@@ -600,7 +663,7 @@ export class OpencodeAdapter implements ProviderAdapter {
600
663
  doom_loop: "allow",
601
664
  external_directory: "allow",
602
665
  },
603
- plugin: [pluginPath],
666
+ plugin: plugins,
604
667
  };
605
668
 
606
669
  // Write per-task config file
@@ -626,6 +689,7 @@ export class OpencodeAdapter implements ProviderAdapter {
626
689
  process.env.SWARM_AGENT_ID = config.agentId;
627
690
  process.env.SWARM_TASK_ID = config.taskId;
628
691
  process.env.SWARM_IS_LEAD = config.role === "lead" ? "true" : "false";
692
+ process.env.CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY = CTX_MODE_NUDGE_EVERY;
629
693
 
630
694
  // Set OPENCODE_CONFIG scoped to the spawn call (save + restore)
631
695
  const prevOpencodeConfig = process.env.OPENCODE_CONFIG;
@@ -711,6 +775,7 @@ export class OpencodeAdapter implements ProviderAdapter {
711
775
  .then(async ({ stream }) => {
712
776
  for await (const event of stream) {
713
777
  session.handleOpencodeEvent(event as OpencodeEvent);
778
+ if (session.isFinished) break;
714
779
  }
715
780
  // Stream ended without session.idle — treat as completion
716
781
  })
package/src/server.ts CHANGED
@@ -6,6 +6,7 @@ import { registerCancelTaskTool } from "./tools/cancel-task";
6
6
  import { registerContextDiffTool } from "./tools/context-diff";
7
7
  import { registerContextHistoryTool } from "./tools/context-history";
8
8
  import { registerCreateChannelTool } from "./tools/create-channel";
9
+ import { registerCreateMetricTool } from "./tools/create-metric";
9
10
  import { registerCreatePageTool } from "./tools/create-page";
10
11
  import { registerDbQueryTool } from "./tools/db-query";
11
12
  import { registerDeleteChannelTool } from "./tools/delete-channel";
@@ -109,6 +110,7 @@ import {
109
110
  registerListConfigTool,
110
111
  registerSetConfigTool,
111
112
  } from "./tools/swarm-config";
113
+ import { registerSwarmXTool } from "./tools/swarm-x";
112
114
  // Task pool capability
113
115
  import { registerTaskActionTool } from "./tools/task-action";
114
116
  // Tracker capability
@@ -146,7 +148,7 @@ import {
146
148
  // Capability-based feature flags
147
149
  // Default: all capabilities enabled
148
150
  const DEFAULT_CAPABILITIES =
149
- "core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
151
+ "core,task-pool,profiles,services,scheduling,memory,workflows,pages,metrics,kv";
150
152
  const CAPABILITIES = new Set(
151
153
  (process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
152
154
  );
@@ -226,6 +228,9 @@ export function createServer() {
226
228
  registerScriptDeleteTool(server);
227
229
  registerScriptQueryTypesTool(server);
228
230
 
231
+ // External command routes - mirrors the `agent-swarm x ...` CLI surface.
232
+ registerSwarmXTool(server);
233
+
229
234
  // Slack integration tools (always registered, will no-op if Slack not configured)
230
235
  registerSlackReplyTool(server);
231
236
  registerSlackReadTool(server);
@@ -338,6 +343,10 @@ export function createServer() {
338
343
  registerCreatePageTool(server);
339
344
  }
340
345
 
346
+ if (hasCapability("metrics")) {
347
+ registerCreateMetricTool(server);
348
+ }
349
+
341
350
  // KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
342
351
  // Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
343
352
  if (hasCapability("kv")) {
@@ -5,7 +5,7 @@
5
5
  * across responses.ts, handlers.ts, thread-buffer.ts).
6
6
  */
7
7
 
8
- import type { TaskAttachment } from "../types";
8
+ import type { AgentTaskStatus, TaskAttachment } from "../types";
9
9
  import { buildAgentFsLiveUrl, getAppUrl } from "../utils/constants";
10
10
 
11
11
  // Slack limits section text to 3000 chars; we use 2900 for safety
@@ -205,7 +205,7 @@ export function formatDuration(start: Date, end: Date): string {
205
205
  export interface TreeNode {
206
206
  taskId: string;
207
207
  agentName: string;
208
- status: "pending" | "in_progress" | "completed" | "failed" | "cancelled";
208
+ status: AgentTaskStatus;
209
209
  progress?: string;
210
210
  duration?: string;
211
211
  slackReplySent?: boolean;
@@ -342,12 +342,20 @@ export function buildBufferFlushBlocks(opts: {
342
342
 
343
343
  // --- Tree rendering ---
344
344
 
345
- const STATUS_ICON: Record<TreeNode["status"], string> = {
345
+ type TreeStatusIcon = TreeNode["status"] | "superseded";
346
+
347
+ const STATUS_ICON: Record<TreeStatusIcon, string> = {
348
+ backlog: "🗂️",
349
+ unassigned: "📭",
350
+ offered: "📨",
351
+ reviewing: "👀",
346
352
  pending: "📡",
347
353
  in_progress: "⏳",
354
+ paused: "⏸️",
348
355
  completed: "✅",
349
356
  failed: "❌",
350
357
  cancelled: "🚫",
358
+ superseded: "↪️",
351
359
  };
352
360
 
353
361
  const MAX_VISIBLE_CHILDREN = 8;
@@ -368,7 +376,7 @@ function truncateOutput(text: string): string {
368
376
  * Render a single node line: icon + bold name + task link + optional duration.
369
377
  */
370
378
  function renderNodeLine(node: TreeNode): string {
371
- const icon = STATUS_ICON[node.status];
379
+ const icon = STATUS_ICON[node.status] ?? "•";
372
380
  const taskLink = getTaskLink(node.taskId);
373
381
  let line = `${icon} *${node.agentName}* (${taskLink})`;
374
382
  if (node.duration) line += ` · ${node.duration}`;
@@ -144,7 +144,7 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
144
144
  childNodes.push({
145
145
  taskId: child.id,
146
146
  agentName: childAgentName,
147
- status: child.status as TreeNode["status"],
147
+ status: child.status,
148
148
  progress: child.progress ?? undefined,
149
149
  duration: childDuration,
150
150
  slackReplySent: child.slackReplySent,
@@ -164,7 +164,7 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
164
164
  nodes.push({
165
165
  taskId: task.id,
166
166
  agentName,
167
- status: task.status as TreeNode["status"],
167
+ status: task.status,
168
168
  progress: task.progress ?? undefined,
169
169
  duration,
170
170
  slackReplySent: task.slackReplySent,
@@ -382,7 +382,7 @@ async function postInitialDMTreeMessage(task: AgentTask): Promise<string | undef
382
382
  const initialNode: TreeNode = {
383
383
  taskId: task.id,
384
384
  agentName: agent.name,
385
- status: task.status as TreeNode["status"],
385
+ status: task.status,
386
386
  progress: task.progress ?? undefined,
387
387
  children: [],
388
388
  };
package/src/telemetry.ts CHANGED
@@ -15,6 +15,7 @@ const TIMEOUT_MS = 5_000;
15
15
  let installationId: string | null = null;
16
16
  let source = "unknown";
17
17
  let cachedIsCloud = false;
18
+ let cachedIsE2b = false;
18
19
 
19
20
  function isEnabled(): boolean {
20
21
  return process.env.ANONYMIZED_TELEMETRY !== "false";
@@ -41,6 +42,15 @@ function isCloudHostname(hostname: string): boolean {
41
42
  return CLOUD_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
42
43
  }
43
44
 
45
+ /**
46
+ * Detect whether the current process is running inside an E2B sandbox.
47
+ * E2B automatically exposes `E2B_SANDBOX_ID` inside every sandbox.
48
+ * Exported for tests; not part of the public API.
49
+ */
50
+ export function _isE2bSandbox(): boolean {
51
+ return typeof process.env.E2B_SANDBOX_ID === "string" && process.env.E2B_SANDBOX_ID.length > 0;
52
+ }
53
+
44
54
  /**
45
55
  * Parse `MCP_BASE_URL` (or any candidate URL) into the cloud flag we ship on
46
56
  * every telemetry event. URL parsing — not substring match — so we never
@@ -100,7 +110,8 @@ export async function initTelemetry(
100
110
 
101
111
  const resolved = _resolveCloudMode(process.env.MCP_BASE_URL);
102
112
  cachedIsCloud = resolved.isCloud;
103
- console.log(`telemetry: cloud=${cachedIsCloud}`);
113
+ cachedIsE2b = _isE2bSandbox();
114
+ console.log(`telemetry: cloud=${cachedIsCloud} e2b=${cachedIsE2b}`);
104
115
 
105
116
  try {
106
117
  const existing = await getConfig("telemetry_installation_id");
@@ -183,6 +194,7 @@ export function track(options: TrackOptions): void {
183
194
  // The hostname is intentionally NOT included — telemetry must stay
184
195
  // anonymous, and the boolean is sufficient to split cloud vs self-host.
185
196
  is_cloud: cachedIsCloud,
197
+ is_e2b: cachedIsE2b,
186
198
  },
187
199
  metadata: {
188
200
  transport: "https",
@@ -212,6 +224,7 @@ export function _resetTelemetryStateForTests(): void {
212
224
  installationId = null;
213
225
  source = "unknown";
214
226
  cachedIsCloud = false;
227
+ cachedIsE2b = false;
215
228
  }
216
229
 
217
230
  /** Test-only: read the resolved install ID. */
@@ -0,0 +1,4 @@
1
+ declare module "*.md" {
2
+ const content: string;
3
+ export default content;
4
+ }
@@ -272,6 +272,41 @@ describe("getBasePrompt — repoContext", () => {
272
272
  expect(result).toContain("Repository Guidelines (MANDATORY)");
273
273
  expect(result).toContain("Auto-merge: Allowed");
274
274
  });
275
+
276
+ test("surfaces swarm-autostash entries when present", async () => {
277
+ const result = await getBasePrompt({
278
+ ...minimalArgs,
279
+ repoContext: {
280
+ claudeMd: "Rules",
281
+ clonePath: "/workspace/my-repo",
282
+ autoStashes: [
283
+ {
284
+ ref: "stash@{0}",
285
+ message: "On main: swarm-autostash main 2026-06-01T13:00:00.000Z",
286
+ },
287
+ ],
288
+ },
289
+ });
290
+
291
+ expect(result).toContain("Pending auto-stashed work exists in this repo");
292
+ expect(result).toContain("stash@{0}: On main: swarm-autostash main");
293
+ expect(result).toContain("git stash apply <ref>");
294
+ expect(result).toContain("git stash pop <ref>");
295
+ });
296
+
297
+ test("does not mention auto-stashed work when no swarm-autostash entries exist", async () => {
298
+ const result = await getBasePrompt({
299
+ ...minimalArgs,
300
+ repoContext: {
301
+ claudeMd: "Rules",
302
+ clonePath: "/workspace/my-repo",
303
+ autoStashes: [],
304
+ },
305
+ });
306
+
307
+ expect(result).not.toContain("Pending auto-stashed work exists in this repo");
308
+ expect(result).not.toContain("git stash apply <ref>");
309
+ });
275
310
  });
276
311
 
277
312
  // ---------------------------------------------------------------------------
@@ -605,6 +640,47 @@ describe("getBasePrompt — local providers unaffected", () => {
605
640
  });
606
641
  });
607
642
 
643
+ // ---------------------------------------------------------------------------
644
+ // Context-mode block — provider gating
645
+ //
646
+ // The context_mode block advertises the `ctx_*` MCP tools. It is included for
647
+ // local providers that have context-mode wired into their per-session config
648
+ // (claude, codex, opencode) and excluded for `pi`, which has no context-mode
649
+ // wiring yet (deferred to DES-514). Remote-provider exclusion is covered by the
650
+ // "remote provider excluded sections" suite above.
651
+ // ---------------------------------------------------------------------------
652
+ const localTraits: ProviderTraits = { hasMcp: true, hasLocalEnvironment: true };
653
+
654
+ describe("getBasePrompt — context-mode provider gating", () => {
655
+ test("excludes context-mode block for pi provider", async () => {
656
+ const result = await getBasePrompt({
657
+ ...minimalArgs,
658
+ traits: localTraits,
659
+ provider: "pi",
660
+ });
661
+ expect(result).not.toContain("Context Window Management");
662
+ expect(result).not.toContain("context-mode");
663
+ });
664
+
665
+ for (const provider of ["claude", "codex", "opencode"] as const) {
666
+ test(`includes context-mode block for ${provider} provider`, async () => {
667
+ const result = await getBasePrompt({
668
+ ...minimalArgs,
669
+ traits: localTraits,
670
+ provider,
671
+ });
672
+ expect(result).toContain("Context Window Management");
673
+ expect(result).toContain("context-mode");
674
+ });
675
+ }
676
+
677
+ test("includes context-mode block when provider is unspecified (local default)", async () => {
678
+ const result = await getBasePrompt({ ...minimalArgs, traits: localTraits });
679
+ expect(result).toContain("Context Window Management");
680
+ expect(result).toContain("context-mode");
681
+ });
682
+ });
683
+
608
684
  describe("getBasePrompt — conditional Slack templates", () => {
609
685
  test("omits Slack tool templates when Slack is disabled", async () => {
610
686
  disableSlackPromptTools();
@@ -11,6 +11,7 @@ import {
11
11
  createAgent,
12
12
  createSessionCost,
13
13
  createTaskExtended,
14
+ createUser,
14
15
  getAgentById,
15
16
  getDb,
16
17
  incrementEmptyPollCount,
@@ -129,6 +130,31 @@ describe("Phase 3 — /api/poll budget admission gate", () => {
129
130
  expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
130
131
  });
131
132
 
133
+ test("pending task trigger includes requester role and notes", async () => {
134
+ const worker = createAgent({ name: "w-requester", isLead: false, status: "idle", maxTasks: 1 });
135
+ const requester = createUser({
136
+ name: "Requester One",
137
+ email: "requester@example.com",
138
+ role: "engineering manager",
139
+ notes: "Include implementation detail and test coverage.",
140
+ });
141
+ createTaskExtended("profile-aware task", {
142
+ agentId: worker.id,
143
+ requestedByUserId: requester.id,
144
+ });
145
+
146
+ const { status, body } = await callPoll(worker.id);
147
+ expect(status).toBe(200);
148
+ if ("error" in body) throw new Error("unexpected error response");
149
+ expect(body.trigger?.type).toBe("task_assigned");
150
+ expect(body.trigger?.requestedBy).toEqual({
151
+ name: "Requester One",
152
+ email: "requester@example.com",
153
+ role: "engineering manager",
154
+ notes: "Include implementation detail and test coverage.",
155
+ });
156
+ });
157
+
132
158
  test("no budgets configured + no work → trigger=null", async () => {
133
159
  const worker = createAgent({ name: "w-empty", isLead: false, status: "idle", maxTasks: 1 });
134
160
  const { status, body } = await callPoll(worker.id);
@@ -246,6 +246,23 @@ describe("mergeMcpConfig (issue #369)", () => {
246
246
  const merged = mergeMcpConfig({ mcpServers: {} }, {}, TASK_ID);
247
247
  expect(Object.keys(merged.mcpServers)).toHaveLength(0);
248
248
  });
249
+
250
+ test("preserves a context-mode entry through the merge", () => {
251
+ const base = {
252
+ mcpServers: {
253
+ "agent-swarm": {
254
+ type: "http",
255
+ url: "http://localhost:3013/mcp",
256
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
257
+ },
258
+ "plugin_context-mode_context-mode": { command: "context-mode" },
259
+ },
260
+ };
261
+ const merged = mergeMcpConfig(base, null, TASK_ID);
262
+ expect(merged.mcpServers["plugin_context-mode_context-mode"]).toEqual({
263
+ command: "context-mode",
264
+ });
265
+ });
249
266
  });
250
267
 
251
268
  describe("createSessionMcpConfig", () => {
@@ -323,7 +340,13 @@ describe("createSessionMcpConfig", () => {
323
340
  const written = await readWritten(path!);
324
341
  expect(written.mcpServers["agent-swarm"]).toBeDefined();
325
342
  expect(written.mcpServers.Datadog).toBeDefined();
326
- expect(Object.keys(written.mcpServers).sort()).toEqual(["Datadog", "agent-swarm"]);
343
+ // context-mode is injected by default (see CONTEXT_MODE_DISABLED gate); the
344
+ // two differently-named .mcp.json servers still merge alongside it.
345
+ expect(Object.keys(written.mcpServers).sort()).toEqual([
346
+ "Datadog",
347
+ "agent-swarm",
348
+ "plugin_context-mode_context-mode",
349
+ ]);
327
350
  });
328
351
 
329
352
  test("ancestor wins over repo-local on agent-swarm key conflict", async () => {
@@ -402,4 +425,66 @@ describe("createSessionMcpConfig", () => {
402
425
  const written = await readWritten(path!);
403
426
  expect(written.mcpServers["from-api"]).toBeDefined();
404
427
  });
428
+
429
+ test("includes context-mode entry when CONTEXT_MODE_DISABLED is unset", async () => {
430
+ const prev = process.env.CONTEXT_MODE_DISABLED;
431
+ delete process.env.CONTEXT_MODE_DISABLED;
432
+ try {
433
+ await writeFile(
434
+ join(sandbox, ".mcp.json"),
435
+ JSON.stringify({
436
+ mcpServers: {
437
+ "agent-swarm": {
438
+ type: "http",
439
+ url: "http://swarm/mcp",
440
+ headers: { Authorization: "Bearer SWARM", "X-Agent-ID": "a1" },
441
+ },
442
+ },
443
+ }),
444
+ );
445
+ const cwd = join(sandbox, "repos", "foo");
446
+ await mkdir(cwd, { recursive: true });
447
+
448
+ const path = await createSessionMcpConfig(cwd, "task-ctx-on");
449
+ const written = await readWritten(path!);
450
+ expect(written.mcpServers["plugin_context-mode_context-mode"]).toEqual({
451
+ command: "context-mode",
452
+ });
453
+ // Coexists with the swarm entry.
454
+ expect(written.mcpServers["agent-swarm"]).toBeDefined();
455
+ } finally {
456
+ if (prev === undefined) delete process.env.CONTEXT_MODE_DISABLED;
457
+ else process.env.CONTEXT_MODE_DISABLED = prev;
458
+ }
459
+ });
460
+
461
+ test("excludes context-mode entry when CONTEXT_MODE_DISABLED='true'", async () => {
462
+ const prev = process.env.CONTEXT_MODE_DISABLED;
463
+ process.env.CONTEXT_MODE_DISABLED = "true";
464
+ try {
465
+ await writeFile(
466
+ join(sandbox, ".mcp.json"),
467
+ JSON.stringify({
468
+ mcpServers: {
469
+ "agent-swarm": {
470
+ type: "http",
471
+ url: "http://swarm/mcp",
472
+ headers: { Authorization: "Bearer SWARM", "X-Agent-ID": "a1" },
473
+ },
474
+ },
475
+ }),
476
+ );
477
+ const cwd = join(sandbox, "repos", "foo");
478
+ await mkdir(cwd, { recursive: true });
479
+
480
+ const path = await createSessionMcpConfig(cwd, "task-ctx-off");
481
+ const written = await readWritten(path!);
482
+ expect(written.mcpServers["plugin_context-mode_context-mode"]).toBeUndefined();
483
+ // The swarm entry is still present.
484
+ expect(written.mcpServers["agent-swarm"]).toBeDefined();
485
+ } finally {
486
+ if (prev === undefined) delete process.env.CONTEXT_MODE_DISABLED;
487
+ else process.env.CONTEXT_MODE_DISABLED = prev;
488
+ }
489
+ });
405
490
  });
@@ -872,9 +872,21 @@ describe("computeCodexCostUsd", () => {
872
872
  describe("buildCodexConfig", () => {
873
873
  // Save and restore the global fetch so we don't leak mocks between tests.
874
874
  const originalFetch = globalThis.fetch;
875
+ // These tests assert the EXACT set of mcp_servers keys, which is only the
876
+ // installed-server merge logic. Disable the always-on context-mode entry so
877
+ // those exact-key assertions stay valid; a dedicated block below verifies
878
+ // the context-mode + features behavior. Save/restore the env to avoid leaks.
879
+ let prevContextModeDisabled: string | undefined;
880
+
881
+ beforeEach(() => {
882
+ prevContextModeDisabled = process.env.CONTEXT_MODE_DISABLED;
883
+ process.env.CONTEXT_MODE_DISABLED = "true";
884
+ });
875
885
 
876
886
  afterEach(() => {
877
887
  globalThis.fetch = originalFetch;
888
+ if (prevContextModeDisabled === undefined) delete process.env.CONTEXT_MODE_DISABLED;
889
+ else process.env.CONTEXT_MODE_DISABLED = prevContextModeDisabled;
878
890
  });
879
891
 
880
892
  // Helper: build a ProviderSessionConfig pointed at a mock endpoint.
@@ -1098,6 +1110,83 @@ describe("buildCodexConfig", () => {
1098
1110
  });
1099
1111
  });
1100
1112
 
1113
+ // ─── Phase 3: buildCodexConfig — context-mode MCP + hook feature flags ───────
1114
+
1115
+ describe("buildCodexConfig — context-mode + features", () => {
1116
+ const originalFetch = globalThis.fetch;
1117
+ // Explicitly own CONTEXT_MODE_DISABLED here. Save the ambient value up front
1118
+ // and restore it after every test so we never leak the mutation to siblings.
1119
+ let prevContextModeDisabled: string | undefined;
1120
+
1121
+ beforeEach(() => {
1122
+ prevContextModeDisabled = process.env.CONTEXT_MODE_DISABLED;
1123
+ });
1124
+
1125
+ afterEach(() => {
1126
+ globalThis.fetch = originalFetch;
1127
+ if (prevContextModeDisabled === undefined) delete process.env.CONTEXT_MODE_DISABLED;
1128
+ else process.env.CONTEXT_MODE_DISABLED = prevContextModeDisabled;
1129
+ });
1130
+
1131
+ function cfg(overrides: Partial<ProviderSessionConfig> = {}): ProviderSessionConfig {
1132
+ return {
1133
+ prompt: "hello",
1134
+ systemPrompt: "",
1135
+ model: "gpt-5.4",
1136
+ role: "worker",
1137
+ agentId: "agent-mcp-test",
1138
+ taskId: "task-mcp-test",
1139
+ apiUrl: "http://test.invalid",
1140
+ apiKey: "test-key",
1141
+ cwd: "",
1142
+ logFile: `/tmp/codex-ctx-test-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
1143
+ ...overrides,
1144
+ };
1145
+ }
1146
+
1147
+ function stubFetch(body: unknown, status = 200): typeof globalThis.fetch {
1148
+ return async (): Promise<Response> => {
1149
+ return new Response(JSON.stringify(body), {
1150
+ status,
1151
+ headers: { "Content-Type": "application/json" },
1152
+ });
1153
+ };
1154
+ }
1155
+
1156
+ test("includes the 'context-mode' mcp_servers entry by default", async () => {
1157
+ delete process.env.CONTEXT_MODE_DISABLED;
1158
+ globalThis.fetch = stubFetch({ servers: [], total: 0 });
1159
+ const merged = await buildCodexConfig(cfg(), "gpt-5.4", () => {});
1160
+ const mcp = merged.mcp_servers as Record<string, Record<string, unknown>>;
1161
+
1162
+ expect(Object.keys(mcp).sort()).toEqual(["agent-swarm", "context-mode"]);
1163
+ expect(mcp["context-mode"]?.command).toBe("context-mode");
1164
+ expect(mcp["context-mode"]?.enabled).toBe(true);
1165
+ expect(mcp["context-mode"]?.startup_timeout_sec).toBe(30);
1166
+ expect(mcp["context-mode"]?.tool_timeout_sec).toBe(120);
1167
+ });
1168
+
1169
+ test("excludes the 'context-mode' entry when CONTEXT_MODE_DISABLED=true", async () => {
1170
+ process.env.CONTEXT_MODE_DISABLED = "true";
1171
+ globalThis.fetch = stubFetch({ servers: [], total: 0 });
1172
+ const merged = await buildCodexConfig(cfg(), "gpt-5.4", () => {});
1173
+ const mcp = merged.mcp_servers as Record<string, Record<string, unknown>>;
1174
+
1175
+ expect(Object.keys(mcp)).toEqual(["agent-swarm"]);
1176
+ expect(mcp["context-mode"]).toBeUndefined();
1177
+ });
1178
+
1179
+ test("sets features.hooks and features.plugin_hooks to true", async () => {
1180
+ delete process.env.CONTEXT_MODE_DISABLED;
1181
+ globalThis.fetch = stubFetch({ servers: [], total: 0 });
1182
+ const merged = await buildCodexConfig(cfg(), "gpt-5.4", () => {});
1183
+
1184
+ const features = merged.features as Record<string, unknown>;
1185
+ expect(features.hooks).toBe(true);
1186
+ expect(features.plugin_hooks).toBe(true);
1187
+ });
1188
+ });
1189
+
1101
1190
  // ─────────────────────────────────────────────────────────────────────────────
1102
1191
  // Phase 3 — session-end summarization
1103
1192
  // ─────────────────────────────────────────────────────────────────────────────