@desplega.ai/agent-swarm 1.87.0 โ†’ 1.88.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 (59) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +13 -1
  3. package/package.json +5 -5
  4. package/src/be/db.ts +49 -7
  5. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  6. package/src/be/modelsdev-cache.json +1123 -1034
  7. package/src/be/seed/registry.ts +3 -2
  8. package/src/be/seed-skills/index.ts +172 -0
  9. package/src/cli.tsx +33 -4
  10. package/src/commands/e2b-stack-wizard.tsx +394 -0
  11. package/src/commands/e2b.ts +1352 -53
  12. package/src/commands/onboard/dashboard-url.ts +29 -0
  13. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  14. package/src/commands/onboard.tsx +3 -1
  15. package/src/commands/runner.ts +1 -0
  16. package/src/e2b/dispatch.ts +234 -18
  17. package/src/http/memory.ts +13 -1
  18. package/src/http/skills.ts +53 -0
  19. package/src/http/webhooks.ts +75 -0
  20. package/src/integrations/kapso/client.ts +82 -0
  21. package/src/memory/automatic-task-gate.ts +47 -0
  22. package/src/prompts/base-prompt.ts +16 -1
  23. package/src/prompts/session-templates.ts +51 -0
  24. package/src/providers/claude-adapter.ts +19 -0
  25. package/src/providers/codex-adapter.ts +22 -0
  26. package/src/providers/ctx-mode-env.ts +10 -0
  27. package/src/providers/opencode-adapter.ts +50 -1
  28. package/src/slack/blocks.ts +12 -4
  29. package/src/slack/watcher.ts +3 -3
  30. package/src/telemetry.ts +14 -1
  31. package/src/templates.d.ts +4 -0
  32. package/src/tests/base-prompt.test.ts +41 -0
  33. package/src/tests/claude-adapter.test.ts +86 -1
  34. package/src/tests/codex-adapter.test.ts +89 -0
  35. package/src/tests/e2b-dispatch.test.ts +603 -11
  36. package/src/tests/http-api-integration.test.ts +113 -0
  37. package/src/tests/kapso-client.test.ts +74 -1
  38. package/src/tests/kapso-inbound.test.ts +60 -2
  39. package/src/tests/opencode-adapter.test.ts +95 -0
  40. package/src/tests/prompt-template-session.test.ts +4 -2
  41. package/src/tests/self-improvement.test.ts +89 -0
  42. package/src/tests/skill-update-scope.test.ts +88 -1
  43. package/src/tests/slack-blocks.test.ts +15 -0
  44. package/src/tests/system-default-skills.test.ts +119 -0
  45. package/src/tests/telemetry-init.test.ts +86 -0
  46. package/src/tools/skills/skill-delete.ts +14 -0
  47. package/src/tools/skills/skill-update.ts +14 -0
  48. package/src/tools/store-progress.ts +19 -5
  49. package/src/types.ts +1 -0
  50. package/templates/skills/artifacts/config.json +1 -0
  51. package/templates/skills/kv-storage/config.json +1 -0
  52. package/templates/skills/pages/config.json +1 -0
  53. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  54. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  55. package/templates/skills/swarm-scripts/config.json +14 -0
  56. package/templates/skills/swarm-scripts/content.md +86 -0
  57. package/templates/skills/workflow-iterate/config.json +1 -0
  58. package/templates/skills/workflow-structured-output/config.json +1 -0
  59. package/tsconfig.json +2 -1
@@ -20,6 +20,14 @@ export interface KapsoSendResult {
20
20
  errorMessage?: string;
21
21
  }
22
22
 
23
+ /** Result of a lightweight message action through the Meta proxy. */
24
+ export interface KapsoMessageActionResult {
25
+ ok: boolean;
26
+ status: number;
27
+ raw: unknown;
28
+ errorMessage?: string;
29
+ }
30
+
23
31
  /** Meta error codes that mean "outside the 24h customer-service window". */
24
32
  const SESSION_WINDOW_ERROR_CODES = new Set([131047, 131051, 470]);
25
33
 
@@ -98,6 +106,80 @@ export async function sendKapsoText(params: {
98
106
  return { ok: true, status: res.status, messageId, raw, sessionWindowExpired: false };
99
107
  }
100
108
 
109
+ /** Mark an inbound WhatsApp message as read, optionally showing the typing indicator. */
110
+ export async function markKapsoMessageRead(params: {
111
+ apiBaseUrl: string;
112
+ apiKey: string;
113
+ phoneNumberId: string;
114
+ messageId: string;
115
+ typingIndicatorType?: "text";
116
+ }): Promise<KapsoMessageActionResult> {
117
+ const url = `${params.apiBaseUrl}/meta/whatsapp/v24.0/${params.phoneNumberId}/messages`;
118
+ const payload: Record<string, unknown> = {
119
+ messaging_product: "whatsapp",
120
+ status: "read",
121
+ message_id: params.messageId,
122
+ };
123
+ if (params.typingIndicatorType) {
124
+ payload.typing_indicator = { type: params.typingIndicatorType };
125
+ }
126
+
127
+ const res = await fetch(url, {
128
+ method: "POST",
129
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
130
+ body: JSON.stringify(payload),
131
+ });
132
+ const raw = await parseJsonSafe(res);
133
+
134
+ if (!res.ok) {
135
+ const { message } = extractMetaError(raw);
136
+ return {
137
+ ok: false,
138
+ status: res.status,
139
+ raw,
140
+ errorMessage: message ?? `Kapso mark-as-read failed with status ${res.status}`,
141
+ };
142
+ }
143
+
144
+ return { ok: true, status: res.status, raw };
145
+ }
146
+
147
+ /** React to an inbound WhatsApp message with an emoji. Pass an empty emoji to clear. */
148
+ export async function sendKapsoReaction(params: {
149
+ apiBaseUrl: string;
150
+ apiKey: string;
151
+ phoneNumberId: string;
152
+ to: string;
153
+ messageId: string;
154
+ emoji: string;
155
+ }): Promise<KapsoMessageActionResult> {
156
+ const url = `${params.apiBaseUrl}/meta/whatsapp/v24.0/${params.phoneNumberId}/messages`;
157
+ const res = await fetch(url, {
158
+ method: "POST",
159
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
160
+ body: JSON.stringify({
161
+ messaging_product: "whatsapp",
162
+ recipient_type: "individual",
163
+ to: params.to,
164
+ type: "reaction",
165
+ reaction: { message_id: params.messageId, emoji: params.emoji },
166
+ }),
167
+ });
168
+ const raw = await parseJsonSafe(res);
169
+
170
+ if (!res.ok) {
171
+ const { message } = extractMetaError(raw);
172
+ return {
173
+ ok: false,
174
+ status: res.status,
175
+ raw,
176
+ errorMessage: message ?? `Kapso reaction failed with status ${res.status}`,
177
+ };
178
+ }
179
+
180
+ return { ok: true, status: res.status, raw };
181
+ }
182
+
101
183
  /** Result of configuring a webhook on a phone number. */
102
184
  export interface KapsoWebhookResult {
103
185
  ok: boolean;
@@ -0,0 +1,47 @@
1
+ const SCHEDULE_TAG_PREFIX = "schedule:";
2
+ const AUTOMATIC_TASK_TYPES = new Set([
3
+ "boot-triage",
4
+ "heartbeat",
5
+ "heartbeat-checklist",
6
+ "health-check",
7
+ "health-probe",
8
+ "monitor",
9
+ "monitoring",
10
+ ]);
11
+
12
+ export interface MemoryGateTask {
13
+ source?: string | null;
14
+ taskType?: string | null;
15
+ tags?: string[] | null;
16
+ }
17
+
18
+ export function isScheduledTaskCompletion(task: { tags?: string[] | null }): boolean {
19
+ return task.tags?.some((tag) => tag.startsWith(SCHEDULE_TAG_PREFIX)) ?? false;
20
+ }
21
+
22
+ export function isAutomaticOrRecurringTaskCompletion(task: MemoryGateTask): boolean {
23
+ const tags = task.tags ?? [];
24
+ const taskType = task.taskType?.toLowerCase();
25
+
26
+ return (
27
+ task.source === "schedule" ||
28
+ task.source === "system" ||
29
+ tags.includes("scheduled") ||
30
+ tags.includes("auto-generated") ||
31
+ tags.some((tag) => tag.startsWith(SCHEDULE_TAG_PREFIX)) ||
32
+ (taskType !== undefined &&
33
+ (AUTOMATIC_TASK_TYPES.has(taskType) ||
34
+ taskType.endsWith("-monitor") ||
35
+ taskType.endsWith("-digest")))
36
+ );
37
+ }
38
+
39
+ export function shouldPersistAutomaticTaskMemory(
40
+ task: MemoryGateTask,
41
+ persistMemory?: boolean,
42
+ ): boolean {
43
+ if (persistMemory) return true;
44
+ return !isAutomaticOrRecurringTaskCompletion(task);
45
+ }
46
+
47
+ export const shouldPersistTaskCompletionMemory = shouldPersistAutomaticTaskMemory;
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ProviderTraits } from "../providers/types";
11
+ import type { ProviderName } from "../types";
11
12
  import { resolveTemplateAsync } from "./resolver";
12
13
 
13
14
  // Side-effect import: register all system + session templates
@@ -55,6 +56,12 @@ export type BasePromptArgs = {
55
56
  swarmUrl: string;
56
57
  capabilities?: string[];
57
58
  traits?: ProviderTraits;
59
+ /**
60
+ * Harness provider for this session. Gates provider-specific prompt blocks
61
+ * (e.g. the context-mode block is excluded for `pi`, which has no
62
+ * context-mode MCP wiring yet โ€” deferred to DES-514).
63
+ */
64
+ provider?: ProviderName;
58
65
  name?: string;
59
66
  description?: string;
60
67
  soulMd?: string;
@@ -91,8 +98,16 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
91
98
  if (!hasMcp) {
92
99
  // If no MCP, role cannot be lead
93
100
  compositeEventType = "system.session.worker.remote";
101
+ } else if (role === "lead") {
102
+ compositeEventType = "system.session.lead";
103
+ } else if (args.provider === "pi") {
104
+ // Pi has no context-mode MCP wiring yet (deferred to DES-514), so it uses a
105
+ // worker composite that omits the context_mode block to avoid advertising
106
+ // phantom `ctx_*` tools. All other local providers (claude, codex, opencode)
107
+ // keep the block via the standard worker composite.
108
+ compositeEventType = "system.session.worker.pi";
94
109
  } else {
95
- compositeEventType = role === "lead" ? "system.session.lead" : "system.session.worker";
110
+ compositeEventType = "system.session.worker";
96
111
  }
97
112
  const compositeResult = await resolveTemplateAsync(compositeEventType, vars);
98
113
  let prompt = compositeResult.text;
@@ -377,6 +377,31 @@ registerTemplate({
377
377
  ### Context Window Management
378
378
 
379
379
  You have access to the \`context-mode\` MCP tools (\`batch_execute\`, \`execute\`, \`execute_file\`, \`search\`, \`fetch_and_index\`, \`index\`) which compress tool output to save context window space. For data-heavy operations (web fetches, large file reads, CLI output processing), prefer these over raw Bash/WebFetch to avoid flooding your context window with raw output.
380
+
381
+ When a tool returns more than a few dozen lines โ€” JSON payloads, log tails, search results, API responses โ€” route it through \`ctx_execute\` or \`ctx_batch_execute\` so only the derived answer enters your conversation. This is especially important for tasks that make many Bash/Read/MCP calls in sequence; each raw response compounds context pressure.
382
+
383
+ ### Agent Scripts โ€” for bulk, repetitive, or data-heavy work
384
+
385
+ Use **scripts** (\`script-upsert\` + \`script-run\`) when a task involves repetitive SDK calls, large data processing, or deterministic multi-step pipelines. Scripts run out-of-process and return only their final result โ€” none of the intermediate output floods your context window.
386
+
387
+ **Decision rubric โ€” when to use scripts vs. other approaches:**
388
+
389
+ | Situation | Preferred approach |
390
+ |---|---|
391
+ | 1โ€“10 SDK calls, result fits in context | Direct tool call |
392
+ | 10+ items, bulk/fan-out SDK ops | **Script** (\`script-run\` with inline source or named) |
393
+ | Heavy data (fetch + parse + transform) | **Script** or \`ctx_*\` (context-mode) |
394
+ | Single expensive web fetch | \`ctx_fetch_and_index\` (context-mode) |
395
+ | Multi-agent fan-out, parallel work, deterministic pipeline | **Workflow** |
396
+ | One-off bash/TS with no reuse needed | \`code-mode run\` (Bash) |
397
+ | Same logic needed across sessions/agents | **Named script** (\`script-upsert\` + reuse) |
398
+
399
+ The 5 script tools (\`script-search\`, \`script-run\`, \`script-upsert\`, \`script-delete\`, \`script-query-types\`) are deferred tools. Call ToolSearch to load \`script-upsert\`, \`script-run\`, and \`script-query-types\` before using them.
400
+
401
+ **Key gotchas:**
402
+ - \`agentId\` IS propagated to scripts via the \`X-Agent-ID\` header.
403
+ - \`taskId\` is NOT propagated to scripts โ€” there is no ambient task context. Pass \`taskId\` explicitly via \`args\` if the script needs to call \`ctx.swarm.task_storeProgress\`.
404
+ - Use \`script-query-types\` to inspect the live \`swarm-sdk.d.ts\` before authoring a complex script.
380
405
  `,
381
406
  variables: [],
382
407
  category: "system",
@@ -586,6 +611,32 @@ registerTemplate({
586
611
  category: "session",
587
612
  });
588
613
 
614
+ // Pi-specific worker composite. Identical to `system.session.worker` except it
615
+ // OMITS the `system.agent.context_mode` block โ€” pi has no context-mode MCP
616
+ // wiring yet (deferred to DES-514), so advertising the `ctx_*` tools to pi
617
+ // workers would point at phantom tools. `getBasePrompt` selects this composite
618
+ // when `provider === 'pi'`; all other local providers (claude, codex, opencode)
619
+ // keep the context_mode block via `system.session.worker`.
620
+ registerTemplate({
621
+ eventType: "system.session.worker.pi",
622
+ header: "",
623
+ defaultBody: `{{@template[system.agent.role]}}
624
+
625
+ {{@template[system.agent.register]}}
626
+ {{@template[system.agent.worker]}}
627
+ {{@template[system.agent.filesystem]}}
628
+ {{@template[system.agent.self_awareness]}}
629
+
630
+ {{@template[system.agent.system]}}
631
+ {{@template[system.agent.share_urls]}}
632
+ {{@template[system.agent.code_quality]}}`,
633
+ variables: [
634
+ { name: "role", description: "The agent's role" },
635
+ { name: "agentId", description: "The agent's unique identifier" },
636
+ ],
637
+ category: "session",
638
+ });
639
+
589
640
  // ============================================================================
590
641
  // Remote provider templates (no MCP, no Docker container)
591
642
  // ============================================================================
@@ -16,6 +16,7 @@ import {
16
16
  } from "../utils/error-tracker";
17
17
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
18
18
  import { scrubSecrets } from "../utils/secret-scrubber";
19
+ import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
19
20
  import { buildOtelTraceparentEnv, isHarnessOtelEnabled } from "./otel-env";
20
21
  import type {
21
22
  CostData,
@@ -256,6 +257,23 @@ export async function createSessionMcpConfig(
256
257
 
257
258
  if (Object.keys(mergedServers).length === 0 && !installedServers) return null;
258
259
 
260
+ // Inject the context-mode stdio MCP server so its `ctx_*` tools survive
261
+ // `--strict-mcp-config` (which restricts Claude to this file and structurally
262
+ // excludes plugin-provided MCP servers). The plugin's hooks still fire via the
263
+ // installed Claude plugin โ€” strict-mcp-config only suppresses MCP servers, not
264
+ // hooks. Placed BEFORE mergeMcpConfig so an API-installed server can still
265
+ // override it (unlikely, but safe). Gated by CONTEXT_MODE_DISABLED so builds
266
+ // and deploys without context-mode don't break.
267
+ //
268
+ // Server key uses the plugin naming convention (`plugin_context-mode_context-mode`)
269
+ // so that the resulting tool names (`mcp__plugin_context-mode_context-mode__ctx_*`)
270
+ // match the names the plugin's hooks reference in guidance text. With the bare
271
+ // key `context-mode`, the tools would be `mcp__context-mode__ctx_*` โ€” callable,
272
+ // but invisible to the hook nudges that point agents at the plugin-prefixed name.
273
+ if (process.env.CONTEXT_MODE_DISABLED !== "true") {
274
+ mergedServers["plugin_context-mode_context-mode"] = { command: "context-mode" };
275
+ }
276
+
259
277
  try {
260
278
  const config = mergeMcpConfig({ mcpServers: mergedServers }, installedServers ?? null, taskId);
261
279
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
@@ -399,6 +417,7 @@ class ClaudeSession implements ProviderSession {
399
417
  ...(sourceEnv.CLAUDE_CODE_OAUTH_TOKEN
400
418
  ? { AGENT_SWARM_CLAUDE_OAUTH_TOKEN: sourceEnv.CLAUDE_CODE_OAUTH_TOKEN }
401
419
  : {}),
420
+ CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY: CTX_MODE_NUDGE_EVERY,
402
421
  } as Record<string, string>,
403
422
  stdout: "pipe",
404
423
  stderr: "pipe",
@@ -82,6 +82,7 @@ import { credentialsToAuthJson } from "./codex-oauth/auth-json.js";
82
82
  import { getValidCodexOAuth } from "./codex-oauth/storage.js";
83
83
  import { resolveCodexPrompt } from "./codex-skill-resolver";
84
84
  import { createCodexSwarmEventHandler } from "./codex-swarm-events";
85
+ import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
85
86
  import { buildOtelTraceparentEnv } from "./otel-env";
86
87
  import type {
87
88
  CostData,
@@ -351,15 +352,34 @@ export async function buildCodexConfig(
351
352
  }
352
353
  }
353
354
 
355
+ // (4) context-mode โ€” pre-installed stdio MCP server providing the `ctx_*`
356
+ // context-compression tools. Gated by `CONTEXT_MODE_DISABLED` so builds /
357
+ // deploys without the `context-mode` binary on PATH don't break the session.
358
+ // Same entry shape as the swarm + installed-server stdio entries above.
359
+ if (process.env.CONTEXT_MODE_DISABLED !== "true") {
360
+ mcpServers["context-mode"] = {
361
+ command: "context-mode",
362
+ enabled: true,
363
+ startup_timeout_sec: 30,
364
+ tool_timeout_sec: 120,
365
+ };
366
+ }
367
+
354
368
  // (1) Baseline overrides. Keep these aligned with the Dockerfile baseline
355
369
  // at `~/.codex/config.toml` (Phase 6). Repeating them here makes local dev
356
370
  // (no baseline file) behave identically to the Docker worker.
371
+ //
372
+ // `features.hooks` / `features.plugin_hooks` enable Codex's hook system and
373
+ // the hooks contributed by installed Codex plugins (context-mode's plugin:
374
+ // routing injection, PreToolUse safety blocks, output capture). The SDK
375
+ // flattens these to `--config features.hooks=true` / `features.plugin_hooks=true`.
357
376
  return {
358
377
  model,
359
378
  approval_policy: "never",
360
379
  sandbox_mode: "danger-full-access",
361
380
  skip_git_repo_check: true,
362
381
  show_raw_agent_reasoning: false,
382
+ features: { hooks: true, plugin_hooks: true },
363
383
  mcp_servers: mcpServers as CodexConfig,
364
384
  };
365
385
  }
@@ -1246,6 +1266,7 @@ export async function createInProcessCodexSession(
1246
1266
  ...(process.env.NODE_EXTRA_CA_CERTS
1247
1267
  ? { NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS }
1248
1268
  : {}),
1269
+ CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY: CTX_MODE_NUDGE_EVERY,
1249
1270
  ...(config.env ?? {}),
1250
1271
  // Gated cross-service OTel linking: when SWARM_ENABLE_HARNESS_OTEL (or
1251
1272
  // the deprecated SWARM_ENABLE_CLAUDE_CODE_OTEL alias) is on, inject
@@ -1420,6 +1441,7 @@ class CodexSubprocessSession implements ProviderSession {
1420
1441
  ? { CODEX_PATH_OVERRIDE: process.env.CODEX_PATH_OVERRIDE }
1421
1442
  : {}),
1422
1443
  ...(process.env.CODEX_SKILLS_DIR ? { CODEX_SKILLS_DIR: process.env.CODEX_SKILLS_DIR } : {}),
1444
+ CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY: CTX_MODE_NUDGE_EVERY,
1423
1445
  ...(process.env.SKIP_SESSION_SUMMARY
1424
1446
  ? { SKIP_SESSION_SUMMARY: process.env.SKIP_SESSION_SUMMARY }
1425
1447
  : {}),
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared context-mode plugin env config for harness subprocesses.
3
+ *
4
+ * The `context-mode` MCP plugin reads `CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY`
5
+ * to decide how often to surface its external-MCP guidance nudge (default 10).
6
+ * We lower it to 3 to increase adoption. All three adapters (claude, codex,
7
+ * opencode) inject this into the subprocess env.
8
+ */
9
+
10
+ export const CTX_MODE_NUDGE_EVERY = process.env.CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY ?? "3";
@@ -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> = [];
@@ -588,6 +615,27 @@ export class OpencodeAdapter implements ProviderAdapter {
588
615
  // an accident, not a contract.
589
616
  const pluginPath = resolvePluginPath();
590
617
 
618
+ // context-mode ships as an in-process opencode plugin (NOT an MCP server).
619
+ // Its built plugin entry registers both the native ctx_* tools and the 5
620
+ // hook surrogates. It must NOT also appear in the `mcp` block โ€” dual
621
+ // registration yields zero tools. We push the ABSOLUTE path to the globally
622
+ // installed package's opencode plugin entry, not the bare name (see
623
+ // resolveContextModePluginPath for why a bare name fails to resolve offline).
624
+ // Gated by CONTEXT_MODE_DISABLED so builds/deploys without it opt out.
625
+ const plugins = [pluginPath];
626
+ if (process.env.CONTEXT_MODE_DISABLED !== "true") {
627
+ const contextModePluginPath = resolveContextModePluginPath();
628
+ if (contextModePluginPath) {
629
+ plugins.push(contextModePluginPath);
630
+ } else {
631
+ console.warn(
632
+ "[opencode] context-mode is enabled but its opencode plugin entry was not found on disk; " +
633
+ "skipping it for this session. Set CONTEXT_MODE_OPENCODE_PLUGIN_PATH to override, or " +
634
+ "CONTEXT_MODE_DISABLED=true to silence.",
635
+ );
636
+ }
637
+ }
638
+
591
639
  // Build per-task opencode config (plugin field carries the swarm plugin)
592
640
  const opencodeConfig: Config & { plugin?: string[] } = {
593
641
  $schema: "https://opencode.ai/config.json",
@@ -600,7 +648,7 @@ export class OpencodeAdapter implements ProviderAdapter {
600
648
  doom_loop: "allow",
601
649
  external_directory: "allow",
602
650
  },
603
- plugin: [pluginPath],
651
+ plugin: plugins,
604
652
  };
605
653
 
606
654
  // Write per-task config file
@@ -626,6 +674,7 @@ export class OpencodeAdapter implements ProviderAdapter {
626
674
  process.env.SWARM_AGENT_ID = config.agentId;
627
675
  process.env.SWARM_TASK_ID = config.taskId;
628
676
  process.env.SWARM_IS_LEAD = config.role === "lead" ? "true" : "false";
677
+ process.env.CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY = CTX_MODE_NUDGE_EVERY;
629
678
 
630
679
  // Set OPENCODE_CONFIG scoped to the spawn call (save + restore)
631
680
  const prevOpencodeConfig = process.env.OPENCODE_CONFIG;
@@ -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
+ }
@@ -605,6 +605,47 @@ describe("getBasePrompt โ€” local providers unaffected", () => {
605
605
  });
606
606
  });
607
607
 
608
+ // ---------------------------------------------------------------------------
609
+ // Context-mode block โ€” provider gating
610
+ //
611
+ // The context_mode block advertises the `ctx_*` MCP tools. It is included for
612
+ // local providers that have context-mode wired into their per-session config
613
+ // (claude, codex, opencode) and excluded for `pi`, which has no context-mode
614
+ // wiring yet (deferred to DES-514). Remote-provider exclusion is covered by the
615
+ // "remote provider excluded sections" suite above.
616
+ // ---------------------------------------------------------------------------
617
+ const localTraits: ProviderTraits = { hasMcp: true, hasLocalEnvironment: true };
618
+
619
+ describe("getBasePrompt โ€” context-mode provider gating", () => {
620
+ test("excludes context-mode block for pi provider", async () => {
621
+ const result = await getBasePrompt({
622
+ ...minimalArgs,
623
+ traits: localTraits,
624
+ provider: "pi",
625
+ });
626
+ expect(result).not.toContain("Context Window Management");
627
+ expect(result).not.toContain("context-mode");
628
+ });
629
+
630
+ for (const provider of ["claude", "codex", "opencode"] as const) {
631
+ test(`includes context-mode block for ${provider} provider`, async () => {
632
+ const result = await getBasePrompt({
633
+ ...minimalArgs,
634
+ traits: localTraits,
635
+ provider,
636
+ });
637
+ expect(result).toContain("Context Window Management");
638
+ expect(result).toContain("context-mode");
639
+ });
640
+ }
641
+
642
+ test("includes context-mode block when provider is unspecified (local default)", async () => {
643
+ const result = await getBasePrompt({ ...minimalArgs, traits: localTraits });
644
+ expect(result).toContain("Context Window Management");
645
+ expect(result).toContain("context-mode");
646
+ });
647
+ });
648
+
608
649
  describe("getBasePrompt โ€” conditional Slack templates", () => {
609
650
  test("omits Slack tool templates when Slack is disabled", async () => {
610
651
  disableSlackPromptTools();