@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.
- package/README.md +5 -1
- package/openapi.json +53 -1
- package/package.json +6 -5
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +374 -9
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3825 -2417
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +179 -0
- package/src/cli.tsx +51 -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 +154 -22
- package/src/commands/x.ts +118 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +13 -1
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/skills.ts +53 -0
- package/src/http/tasks.ts +4 -1
- package/src/http/webhooks.ts +75 -0
- package/src/http/workflows.ts +5 -1
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +24 -1
- package/src/prompts/session-templates.ts +74 -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 +72 -7
- package/src/server.ts +10 -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 +76 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +116 -1
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +185 -30
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- 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/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +122 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -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/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +121 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -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,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.
|
|
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.
|
|
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.
|
|
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:
|
|
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")) {
|
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. */
|
|
@@ -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
|
-
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────
|