@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.
- package/README.md +2 -1
- package/openapi.json +13 -1
- package/package.json +5 -5
- package/src/be/db.ts +49 -7
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/modelsdev-cache.json +1123 -1034
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +172 -0
- package/src/cli.tsx +33 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +1 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/http/memory.ts +13 -1
- package/src/http/skills.ts +53 -0
- package/src/http/webhooks.ts +75 -0
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/prompts/base-prompt.ts +16 -1
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +50 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +41 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/http-api-integration.test.ts +113 -0
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/opencode-adapter.test.ts +95 -0
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/system-default-skills.test.ts +119 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/types.ts +1 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- 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 =
|
|
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:
|
|
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;
|
package/src/slack/blocks.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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}`;
|
package/src/slack/watcher.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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. */
|
|
@@ -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();
|